stapel-recordings 0.1.0__tar.gz

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 (60) hide show
  1. stapel_recordings-0.1.0/LICENSE +21 -0
  2. stapel_recordings-0.1.0/PKG-INFO +110 -0
  3. stapel_recordings-0.1.0/README.md +91 -0
  4. stapel_recordings-0.1.0/__init__.py +31 -0
  5. stapel_recordings-0.1.0/actions.py +51 -0
  6. stapel_recordings-0.1.0/admin.py +43 -0
  7. stapel_recordings-0.1.0/apps.py +27 -0
  8. stapel_recordings-0.1.0/checks.py +66 -0
  9. stapel_recordings-0.1.0/conf.py +72 -0
  10. stapel_recordings-0.1.0/conftest.py +78 -0
  11. stapel_recordings-0.1.0/dto.py +71 -0
  12. stapel_recordings-0.1.0/errors.py +25 -0
  13. stapel_recordings-0.1.0/events.py +112 -0
  14. stapel_recordings-0.1.0/gdpr.py +66 -0
  15. stapel_recordings-0.1.0/management/__init__.py +0 -0
  16. stapel_recordings-0.1.0/management/commands/__init__.py +0 -0
  17. stapel_recordings-0.1.0/management/commands/recordings_reconcile.py +98 -0
  18. stapel_recordings-0.1.0/migrations/0001_initial.py +177 -0
  19. stapel_recordings-0.1.0/migrations/__init__.py +0 -0
  20. stapel_recordings-0.1.0/models.py +266 -0
  21. stapel_recordings-0.1.0/normalize.py +114 -0
  22. stapel_recordings-0.1.0/pipeline.py +211 -0
  23. stapel_recordings-0.1.0/py.typed +0 -0
  24. stapel_recordings-0.1.0/pyproject.toml +51 -0
  25. stapel_recordings-0.1.0/schemas/emits/recording.completed.json +18 -0
  26. stapel_recordings-0.1.0/schemas/emits/recording.failed.json +15 -0
  27. stapel_recordings-0.1.0/schemas/emits/recording.stage.json +12 -0
  28. stapel_recordings-0.1.0/schemas/emits/recording.stage_completed.json +15 -0
  29. stapel_recordings-0.1.0/schemas/emits/recording.uploaded.json +16 -0
  30. stapel_recordings-0.1.0/serializers.py +34 -0
  31. stapel_recordings-0.1.0/services.py +125 -0
  32. stapel_recordings-0.1.0/setup.cfg +4 -0
  33. stapel_recordings-0.1.0/stages.py +450 -0
  34. stapel_recordings-0.1.0/stapel_recordings.egg-info/PKG-INFO +110 -0
  35. stapel_recordings-0.1.0/stapel_recordings.egg-info/SOURCES.txt +108 -0
  36. stapel_recordings-0.1.0/stapel_recordings.egg-info/dependency_links.txt +1 -0
  37. stapel_recordings-0.1.0/stapel_recordings.egg-info/requires.txt +7 -0
  38. stapel_recordings-0.1.0/stapel_recordings.egg-info/top_level.txt +1 -0
  39. stapel_recordings-0.1.0/storage.py +332 -0
  40. stapel_recordings-0.1.0/tests/__init__.py +0 -0
  41. stapel_recordings-0.1.0/tests/conftest.py +152 -0
  42. stapel_recordings-0.1.0/tests/fakes.py +108 -0
  43. stapel_recordings-0.1.0/tests/test_api.py +61 -0
  44. stapel_recordings-0.1.0/tests/test_checks.py +30 -0
  45. stapel_recordings-0.1.0/tests/test_gdpr.py +59 -0
  46. stapel_recordings-0.1.0/tests/test_models.py +53 -0
  47. stapel_recordings-0.1.0/tests/test_pipeline.py +89 -0
  48. stapel_recordings-0.1.0/tests/test_pipeline_extension.py +120 -0
  49. stapel_recordings-0.1.0/tests/test_public_api.py +46 -0
  50. stapel_recordings-0.1.0/tests/test_reconcile.py +55 -0
  51. stapel_recordings-0.1.0/tests/test_retry_dlq.py +60 -0
  52. stapel_recordings-0.1.0/tests/test_schemas.py +35 -0
  53. stapel_recordings-0.1.0/tests/test_storage_seam.py +64 -0
  54. stapel_recordings-0.1.0/tests/test_summarize.py +47 -0
  55. stapel_recordings-0.1.0/tests/test_transcribe.py +111 -0
  56. stapel_recordings-0.1.0/tests/test_upload.py +55 -0
  57. stapel_recordings-0.1.0/tests/urls.py +5 -0
  58. stapel_recordings-0.1.0/transcript_schema.py +318 -0
  59. stapel_recordings-0.1.0/urls.py +13 -0
  60. stapel_recordings-0.1.0/views.py +120 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stapel contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-recordings
3
+ Version: 0.1.0
4
+ Summary: Recording lifecycle and transcription for the Stapel framework
5
+ License: MIT
6
+ Keywords: django,stapel,recordings
7
+ Classifier: Framework :: Django
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: stapel-core<0.4,>=0.3.0
14
+ Provides-Extra: s3
15
+ Requires-Dist: boto3>=1.34; extra == "s3"
16
+ Provides-Extra: all
17
+ Requires-Dist: stapel-recordings[s3]; extra == "all"
18
+ Dynamic: license-file
19
+
20
+ # stapel-recordings
21
+
22
+ Recording lifecycle and transcription for the [Stapel framework](https://github.com/usestapel) —
23
+ composable Django apps that deploy as a monolith or as microservices
24
+ without changing module code.
25
+
26
+ Owns the lifecycle **capture/upload → storage → transcribe → summarize**:
27
+ `Recording` + `Speaker` + `Segment` (the unified transcript), presigned /
28
+ multipart upload sessions, and a data-driven, outbox-backed processing
29
+ pipeline with retry, DLQ and reconcile.
30
+
31
+ Speech-to-text and summarization are **delegated to
32
+ [stapel-agent](https://github.com/usestapel/stapel-agent)** via the
33
+ `llm.transcribe` / `llm.summarize` comm Functions — this module does not
34
+ implement STT or LLM calls. Object storage goes through a swappable seam.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install stapel-recordings # default: Django-storage backend
40
+ pip install "stapel-recordings[s3]" # + boto3 S3/MinIO backend
41
+ ```
42
+
43
+ ```python
44
+ INSTALLED_APPS = [
45
+ # ...
46
+ "stapel_core.django.outbox", # transactional outbox (pipeline reliability)
47
+ "stapel_recordings",
48
+ ]
49
+
50
+ # urls.py
51
+ path("recordings/", include("stapel_recordings.urls"))
52
+ ```
53
+
54
+ The `transcribe` / `merge` stages call stapel-agent by comm name — install
55
+ and configure stapel-agent (or provide `llm.transcribe` / `llm.summarize`
56
+ providers) for the pipeline to complete. The default `convert` stage needs
57
+ `ffmpeg`/`ffprobe` on PATH (or set `NORMALIZER` to `passthrough_normalize`).
58
+
59
+ ## The pipeline is data you can edit
60
+
61
+ ```python
62
+ STAPEL_RECORDINGS = {
63
+ # Reorder / subset / insert stages — no fork:
64
+ "PIPELINE": ["convert", "transcribe", "redact_pii", "merge"],
65
+ # Replace or add stage handlers (merge-over-builtins; None removes):
66
+ "STAGES": {"diarize": "myproject.stages.PyannoteDiarizer"},
67
+ # Or source the list at runtime (DB / per-workspace / per-recording):
68
+ "PIPELINE_RESOLVER": "myproject.pipelines.resolve",
69
+ # Swap the object store:
70
+ "STORAGE": "stapel_recordings.storage.S3Backend",
71
+ }
72
+ ```
73
+
74
+ A generic driver runs the resolved stage list, advancing the status machine
75
+ and emitting the next stage through the outbox. See **[MODULE.md](MODULE.md)**
76
+ for the stage contract and worked examples.
77
+
78
+ ## Settings
79
+
80
+ All configuration lives in the `STAPEL_RECORDINGS` namespace (dict setting,
81
+ flat setting, or env var — resolved lazily). See the full table in
82
+ [MODULE.md](MODULE.md). Highlights: `PIPELINE`, `STAGES`,
83
+ `PIPELINE_RESOLVER`, `STORAGE`, `NORMALIZER`, `SUMMARIZE_ENABLED`,
84
+ `MAX_STAGE_RETRIES`.
85
+
86
+ ## comm surface
87
+
88
+ | Kind | Name | Contract |
89
+ |---|---|---|
90
+ | Action (emit) | `recording.uploaded`, `recording.stage_completed`, `recording.completed`, `recording.failed` | pipeline lifecycle (public) |
91
+ | Action (consume) | `recording.uploaded`, `recording.stage`, `user.deleted` | driver + GDPR erase |
92
+ | Function (call) | `llm.transcribe`, `llm.summarize` | provided by stapel-agent |
93
+
94
+ ## Operations
95
+
96
+ ```bash
97
+ python manage.py recordings_reconcile --once # re-drive stuck recordings
98
+ ```
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ pip install -e . && pip install pytest pytest-django ruff jsonschema djangorestframework
104
+ ./setup-hooks.sh
105
+ pytest tests/
106
+ ```
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,91 @@
1
+ # stapel-recordings
2
+
3
+ Recording lifecycle and transcription for the [Stapel framework](https://github.com/usestapel) —
4
+ composable Django apps that deploy as a monolith or as microservices
5
+ without changing module code.
6
+
7
+ Owns the lifecycle **capture/upload → storage → transcribe → summarize**:
8
+ `Recording` + `Speaker` + `Segment` (the unified transcript), presigned /
9
+ multipart upload sessions, and a data-driven, outbox-backed processing
10
+ pipeline with retry, DLQ and reconcile.
11
+
12
+ Speech-to-text and summarization are **delegated to
13
+ [stapel-agent](https://github.com/usestapel/stapel-agent)** via the
14
+ `llm.transcribe` / `llm.summarize` comm Functions — this module does not
15
+ implement STT or LLM calls. Object storage goes through a swappable seam.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install stapel-recordings # default: Django-storage backend
21
+ pip install "stapel-recordings[s3]" # + boto3 S3/MinIO backend
22
+ ```
23
+
24
+ ```python
25
+ INSTALLED_APPS = [
26
+ # ...
27
+ "stapel_core.django.outbox", # transactional outbox (pipeline reliability)
28
+ "stapel_recordings",
29
+ ]
30
+
31
+ # urls.py
32
+ path("recordings/", include("stapel_recordings.urls"))
33
+ ```
34
+
35
+ The `transcribe` / `merge` stages call stapel-agent by comm name — install
36
+ and configure stapel-agent (or provide `llm.transcribe` / `llm.summarize`
37
+ providers) for the pipeline to complete. The default `convert` stage needs
38
+ `ffmpeg`/`ffprobe` on PATH (or set `NORMALIZER` to `passthrough_normalize`).
39
+
40
+ ## The pipeline is data you can edit
41
+
42
+ ```python
43
+ STAPEL_RECORDINGS = {
44
+ # Reorder / subset / insert stages — no fork:
45
+ "PIPELINE": ["convert", "transcribe", "redact_pii", "merge"],
46
+ # Replace or add stage handlers (merge-over-builtins; None removes):
47
+ "STAGES": {"diarize": "myproject.stages.PyannoteDiarizer"},
48
+ # Or source the list at runtime (DB / per-workspace / per-recording):
49
+ "PIPELINE_RESOLVER": "myproject.pipelines.resolve",
50
+ # Swap the object store:
51
+ "STORAGE": "stapel_recordings.storage.S3Backend",
52
+ }
53
+ ```
54
+
55
+ A generic driver runs the resolved stage list, advancing the status machine
56
+ and emitting the next stage through the outbox. See **[MODULE.md](MODULE.md)**
57
+ for the stage contract and worked examples.
58
+
59
+ ## Settings
60
+
61
+ All configuration lives in the `STAPEL_RECORDINGS` namespace (dict setting,
62
+ flat setting, or env var — resolved lazily). See the full table in
63
+ [MODULE.md](MODULE.md). Highlights: `PIPELINE`, `STAGES`,
64
+ `PIPELINE_RESOLVER`, `STORAGE`, `NORMALIZER`, `SUMMARIZE_ENABLED`,
65
+ `MAX_STAGE_RETRIES`.
66
+
67
+ ## comm surface
68
+
69
+ | Kind | Name | Contract |
70
+ |---|---|---|
71
+ | Action (emit) | `recording.uploaded`, `recording.stage_completed`, `recording.completed`, `recording.failed` | pipeline lifecycle (public) |
72
+ | Action (consume) | `recording.uploaded`, `recording.stage`, `user.deleted` | driver + GDPR erase |
73
+ | Function (call) | `llm.transcribe`, `llm.summarize` | provided by stapel-agent |
74
+
75
+ ## Operations
76
+
77
+ ```bash
78
+ python manage.py recordings_reconcile --once # re-drive stuck recordings
79
+ ```
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ pip install -e . && pip install pytest pytest-django ruff jsonschema djangorestframework
85
+ ./setup-hooks.sh
86
+ pytest tests/
87
+ ```
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,31 @@
1
+ """stapel-recordings — Recording lifecycle and transcription for the Stapel framework.
2
+
3
+ Public API (lazily exported, PEP 562 — importing this package never pulls
4
+ in Django or requires configured settings):
5
+
6
+ - ``recordings_settings`` — resolved app settings (``stapel_recordings.conf``).
7
+ """
8
+
9
+ __all__ = [
10
+ "recordings_settings",
11
+ ]
12
+
13
+ # name -> submodule that defines it. Resolution is deferred until first
14
+ # attribute access so that `import stapel_recordings` stays Django-free.
15
+ _LAZY_EXPORTS = {
16
+ "recordings_settings": ".conf",
17
+ }
18
+
19
+
20
+ def __getattr__(name):
21
+ if name in _LAZY_EXPORTS:
22
+ from importlib import import_module
23
+
24
+ value = getattr(import_module(_LAZY_EXPORTS[name], __name__), name)
25
+ globals()[name] = value # cache for subsequent lookups
26
+ return value
27
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
28
+
29
+
30
+ def __dir__():
31
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,51 @@
1
+ """Action subscriptions of stapel-recordings.
2
+
3
+ Handlers are idempotent (delivery is at-least-once — outbox retries, broker
4
+ redelivery). Transport is chosen by ``STAPEL_COMM`` (in-process in a
5
+ monolith, bus consumer in microservices); the handler code is identical.
6
+ """
7
+ import logging
8
+
9
+ from stapel_core.comm import on_action
10
+
11
+ from . import events
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @on_action(events.ACTION_UPLOADED)
17
+ def handle_uploaded(event):
18
+ """A file landed — start the pipeline driver."""
19
+ from .pipeline import start_pipeline
20
+
21
+ recording_id = event.payload.get("recording_id")
22
+ if not recording_id:
23
+ logger.error("recording.uploaded without recording_id: %s", event.event_id)
24
+ return
25
+ start_pipeline(recording_id)
26
+
27
+
28
+ @on_action(events.ACTION_STAGE)
29
+ def handle_stage(event):
30
+ """Run one stage of the resolved pipeline (the generic driver step)."""
31
+ from .pipeline import run_stage
32
+
33
+ recording_id = event.payload.get("recording_id")
34
+ stage_index = event.payload.get("stage_index")
35
+ if recording_id is None or stage_index is None:
36
+ logger.error("recording.stage missing fields: %s", event.event_id)
37
+ return
38
+ run_stage(recording_id, int(stage_index))
39
+
40
+
41
+ @on_action("user.deleted")
42
+ def handle_user_deleted(event):
43
+ """Erase a user's recordings + their storage objects (GDPR Art. 17)."""
44
+ from .gdpr import RecordingsGDPRProvider
45
+
46
+ user_id = event.payload.get("user_id")
47
+ if not user_id:
48
+ logger.error("user.deleted event without user_id: %s", event.event_id)
49
+ return
50
+ RecordingsGDPRProvider().delete(user_id)
51
+ logger.info("recordings erased for deleted user %s", user_id)
@@ -0,0 +1,43 @@
1
+ """Read-only admin for the recordings journal models."""
2
+ from django.contrib import admin
3
+
4
+ from .models import Job, Recording, Segment, Speaker, UploadSession
5
+
6
+
7
+ class _ReadOnlyAdmin(admin.ModelAdmin):
8
+ def has_add_permission(self, request):
9
+ return False
10
+
11
+ def has_change_permission(self, request, obj=None):
12
+ return False
13
+
14
+ def has_delete_permission(self, request, obj=None):
15
+ return False
16
+
17
+
18
+ @admin.register(Recording)
19
+ class RecordingAdmin(_ReadOnlyAdmin):
20
+ list_display = ("id", "title", "status", "workspace_id", "provider_used", "created_at")
21
+ list_filter = ("status", "source_type")
22
+ search_fields = ("id", "title", "workspace_id")
23
+
24
+
25
+ @admin.register(Speaker)
26
+ class SpeakerAdmin(_ReadOnlyAdmin):
27
+ list_display = ("id", "recording", "label", "display_name")
28
+
29
+
30
+ @admin.register(Segment)
31
+ class SegmentAdmin(_ReadOnlyAdmin):
32
+ list_display = ("id", "recording", "sequence_num", "start_time", "end_time")
33
+
34
+
35
+ @admin.register(UploadSession)
36
+ class UploadSessionAdmin(_ReadOnlyAdmin):
37
+ list_display = ("id", "recording", "is_multipart", "finalized_at", "expires_at")
38
+
39
+
40
+ @admin.register(Job)
41
+ class JobAdmin(_ReadOnlyAdmin):
42
+ list_display = ("id", "recording", "type", "status", "progress_percent")
43
+ list_filter = ("type", "status")
@@ -0,0 +1,27 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class RecordingsConfig(AppConfig):
5
+ name = "stapel_recordings"
6
+ label = "recordings"
7
+ verbose_name = "Recording lifecycle and transcription"
8
+ default_auto_field = "django.db.models.BigAutoField"
9
+
10
+ def ready(self):
11
+ # Import-time side effects: system checks, error-key registration,
12
+ # action subscriptions (pipeline driver + GDPR consumer). Keep each
13
+ # in its own module.
14
+ from . import checks # noqa: F401
15
+ from . import errors # noqa: F401
16
+
17
+ # Action subscriptions (in-process in a monolith, bus consumer in
18
+ # microservices — same code, transport chosen by STAPEL_COMM).
19
+ from . import actions # noqa: F401
20
+
21
+ # GDPR provider registration (monolith mode).
22
+ from stapel_core.gdpr import gdpr_registry
23
+
24
+ from .gdpr import RecordingsGDPRProvider
25
+
26
+ if RecordingsGDPRProvider().section not in gdpr_registry.sections:
27
+ gdpr_registry.register(RecordingsGDPRProvider())
@@ -0,0 +1,66 @@
1
+ """Django system checks for stapel-recordings configuration.
2
+
3
+ Policy (docs/library-standard.md §3.7): E-level for configuration the
4
+ service cannot run with; W-level for entries that degrade lazily (a broken
5
+ *unused* dotted path must not block deploys).
6
+ """
7
+ from django.core import checks
8
+
9
+
10
+ @checks.register(checks.Tags.compatibility)
11
+ def check_storage_backend(app_configs, **kwargs):
12
+ """E: the STORAGE seam must resolve to a RecordingStorage subclass."""
13
+ from .conf import recordings_settings
14
+ from .storage import RecordingStorage
15
+
16
+ try:
17
+ cls = recordings_settings.STORAGE
18
+ except Exception as exc:
19
+ return [checks.Error(
20
+ f"STAPEL_RECORDINGS['STORAGE'] could not be imported: {exc}",
21
+ id="stapel_recordings.E001",
22
+ )]
23
+ if not (isinstance(cls, type) and issubclass(cls, RecordingStorage)):
24
+ return [checks.Error(
25
+ "STAPEL_RECORDINGS['STORAGE'] must be a RecordingStorage subclass.",
26
+ id="stapel_recordings.E002",
27
+ )]
28
+ return []
29
+
30
+
31
+ @checks.register(checks.Tags.compatibility)
32
+ def check_pipeline_stages(app_configs, **kwargs):
33
+ """W: every stage named in PIPELINE should resolve in the registry; the
34
+ NORMALIZER / PIPELINE_RESOLVER seams should be importable and callable."""
35
+ from .conf import recordings_settings
36
+ from .stages import resolve_stages
37
+
38
+ warnings = []
39
+ try:
40
+ known = set(resolve_stages().keys())
41
+ except Exception as exc:
42
+ return [checks.Warning(
43
+ f"STAPEL_RECORDINGS['STAGES'] overlay could not be resolved: {exc}",
44
+ id="stapel_recordings.W001",
45
+ )]
46
+ for name in recordings_settings.PIPELINE:
47
+ if name not in known:
48
+ warnings.append(checks.Warning(
49
+ f"PIPELINE references stage {name!r} that is not registered "
50
+ "(register it via register_stage or add it to STAGES).",
51
+ id="stapel_recordings.W002",
52
+ ))
53
+ for key in ("NORMALIZER", "PIPELINE_RESOLVER"):
54
+ try:
55
+ value = getattr(recordings_settings, key)
56
+ if not callable(value):
57
+ warnings.append(checks.Warning(
58
+ f"STAPEL_RECORDINGS['{key}'] is not callable.",
59
+ id="stapel_recordings.W003",
60
+ ))
61
+ except Exception as exc:
62
+ warnings.append(checks.Warning(
63
+ f"STAPEL_RECORDINGS['{key}'] could not be imported: {exc}",
64
+ id="stapel_recordings.W004",
65
+ ))
66
+ return warnings
@@ -0,0 +1,72 @@
1
+ """Settings namespace for stapel-recordings.
2
+
3
+ All configuration is read through ``recordings_settings`` (lazily, at call
4
+ time) — never via module-level ``os.getenv`` (values would freeze at import).
5
+ Resolution order per key: ``settings.STAPEL_RECORDINGS`` dict -> flat Django
6
+ setting of the same name -> environment variable -> default below.
7
+
8
+ Dotted-path keys listed in ``import_strings`` are resolved with
9
+ ``import_string`` — the fork-free escape hatch for swappable behavior
10
+ (the STORAGE / NORMALIZER strategies and the PIPELINE_RESOLVER seam).
11
+
12
+ The flagship extension point is the **pipeline**: an ordered list of stage
13
+ names (``PIPELINE``) run by a generic driver over an open stage registry
14
+ (``STAGES`` overlay + ``register_stage`` runtime API). Reorder, subset,
15
+ insert or replace stages without forking. See MODULE.md.
16
+ """
17
+ from stapel_core.conf import AppSettings
18
+
19
+ #: Default pipeline: the four built-in stages in canonical order. Hosts
20
+ #: override with STAPEL_RECORDINGS["PIPELINE"] (reorder / subset / extend)
21
+ #: or a PIPELINE_RESOLVER for runtime/per-recording definitions.
22
+ DEFAULT_PIPELINE = ("convert", "transcribe", "diarize", "merge")
23
+
24
+ recordings_settings = AppSettings(
25
+ "STAPEL_RECORDINGS",
26
+ defaults={
27
+ # ── Pipeline (flagship extension point) ──────────────────────
28
+ # Ordered stage-name list run by the generic driver. A host can
29
+ # reorder, drop (e.g. skip "diarize") or insert stages (e.g. a
30
+ # "redact_pii" before "merge") purely by changing this list.
31
+ "PIPELINE": list(DEFAULT_PIPELINE),
32
+ # Overlay of custom/replacement stage handlers: {name: dotted-path}.
33
+ # Merge-over-builtins semantics; a value of None removes a built-in.
34
+ "STAGES": {},
35
+ # Resolver seam: dotted path to ``(recording) -> list[str]``. The
36
+ # default returns the PIPELINE setting; point it at a DB / per-
37
+ # workspace source to let operators edit pipelines at runtime.
38
+ "PIPELINE_RESOLVER": "stapel_recordings.pipeline.default_pipeline_resolver",
39
+
40
+ # ── Storage seam (single strategy, replace) ──────────────────
41
+ # Dotted path to a RecordingStorage implementation. Default is a
42
+ # Django-storage backend (works with any DEFAULT_FILE_STORAGE);
43
+ # swap for the bundled S3/MinIO backend or your own.
44
+ "STORAGE": "stapel_recordings.storage.DjangoStorageBackend",
45
+ "STORAGE_PREFIX": "recordings",
46
+
47
+ # ── Audio normalization seam (single strategy, replace) ──────
48
+ # Dotted path to ``(src_path, dst_path) -> float|None`` returning
49
+ # duration seconds. Default shells out to ffmpeg; a passthrough is
50
+ # provided for environments without ffmpeg / for tests.
51
+ "NORMALIZER": "stapel_recordings.normalize.ffmpeg_normalize",
52
+
53
+ # ── Upload sessions ──────────────────────────────────────────
54
+ "UPLOAD_SESSION_TTL_SECONDS": 15 * 60,
55
+ "MULTIPART_SESSION_TTL_SECONDS": 24 * 60 * 60,
56
+ "MULTIPART_PART_SIZE": 10 * 1024 * 1024,
57
+ "MAX_UPLOAD_BYTES": 2 * 1024 * 1024 * 1024,
58
+
59
+ # ── Transcription / summarization (delegated to stapel-agent) ─
60
+ "TRANSCRIBE_TIMEOUT_SECONDS": 1800,
61
+ "MAX_STAGE_RETRIES": 3,
62
+ "SUMMARIZE_ENABLED": True,
63
+ "SUMMARIZE_MODEL": "medium",
64
+
65
+ # ── Reconcile watchdog ───────────────────────────────────────
66
+ "STUCK_THRESHOLD_SECONDS": 10 * 60,
67
+ "ABANDONED_UPLOAD_THRESHOLD_SECONDS": 60 * 60,
68
+ },
69
+ import_strings=("STORAGE", "NORMALIZER", "PIPELINE_RESOLVER"),
70
+ )
71
+
72
+ __all__ = ["recordings_settings", "DEFAULT_PIPELINE"]
@@ -0,0 +1,78 @@
1
+ def pytest_configure(config):
2
+ from django.conf import settings
3
+ if not settings.configured:
4
+ settings.configure(
5
+ SECRET_KEY="test-secret-key-not-for-production",
6
+ INSTALLED_APPS=[
7
+ "django.contrib.contenttypes",
8
+ "django.contrib.auth",
9
+ "django.contrib.sessions",
10
+ "django.contrib.admin",
11
+ "django.contrib.messages",
12
+ "stapel_core.django.users",
13
+ "stapel_core.django.outbox",
14
+ "rest_framework",
15
+ "stapel_recordings",
16
+ ],
17
+ AUTH_USER_MODEL="users.User",
18
+ DATABASES={
19
+ "default": {
20
+ "ENGINE": "django.db.backends.sqlite3",
21
+ "NAME": ":memory:",
22
+ }
23
+ },
24
+ DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
25
+ USE_TZ=True,
26
+ ROOT_URLCONF="stapel_recordings.tests.urls",
27
+ MEDIA_ROOT="/tmp/stapel-recordings-test-media",
28
+ CACHES={
29
+ "default": {
30
+ "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
31
+ }
32
+ },
33
+ # Realistic comm: Actions leave through the transactional outbox
34
+ # (so producer/consumer can be tested as split synchronous halves,
35
+ # §7.21) and schema validation is ON — the committed contracts in
36
+ # schemas/ are enforced by the tests.
37
+ STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
38
+ STAPEL_COMM={
39
+ "OUTBOX_ENABLED": True,
40
+ "ACTION_TRANSPORT": "inprocess",
41
+ "FUNCTION_TRANSPORT": "inprocess",
42
+ "VALIDATE_SCHEMAS": True,
43
+ },
44
+ MIGRATION_MODULES={
45
+ "users": None,
46
+ "recordings": None,
47
+ },
48
+ )
49
+ import django
50
+ django.setup()
51
+
52
+ from stapel_core.comm.schemas import autoload_schemas
53
+ autoload_schemas()
54
+
55
+
56
+ import pytest # noqa: E402
57
+
58
+
59
+ @pytest.fixture
60
+ def api_client():
61
+ from rest_framework.test import APIClient
62
+ return APIClient()
63
+
64
+
65
+ @pytest.fixture(autouse=True)
66
+ def _reset_recordings_state():
67
+ """Isolate runtime stage registrations, the storage-backend cache, and
68
+ any stub comm Functions between tests."""
69
+ from stapel_core.comm.registry import function_registry
70
+
71
+ from stapel_recordings import stages, storage
72
+
73
+ yield
74
+ stages.reset_runtime_stages()
75
+ storage.reset_storage_cache()
76
+ for name in ("llm.transcribe", "llm.summarize"):
77
+ function_registry._providers.pop(name, None)
78
+ function_registry._schemas.pop(name, None)
@@ -0,0 +1,71 @@
1
+ """Dataclass DTOs — the API models of stapel-recordings (never ORM instances)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class RecordingDTO:
10
+ """A recording as seen by the API."""
11
+
12
+ id: str
13
+ workspace_id: str
14
+ title: str
15
+ status: str
16
+ source_type: str
17
+ language: Optional[str]
18
+ duration_seconds: Optional[float]
19
+ segments_count: int
20
+ speakers_count: int
21
+ word_count: int
22
+ provider_used: Optional[str]
23
+ transcript_storage_key: Optional[str]
24
+ summary: Optional[str]
25
+ created_at: str
26
+
27
+
28
+ @dataclass
29
+ class UploadSessionDTO:
30
+ """A single-PUT upload session."""
31
+
32
+ id: str
33
+ presigned_url: str
34
+ storage_key: str
35
+ max_size_bytes: int
36
+ expires_at: str
37
+
38
+
39
+ @dataclass
40
+ class CreateRecordingResponse:
41
+ recording: RecordingDTO
42
+ upload: UploadSessionDTO
43
+
44
+
45
+ def recording_to_dto(recording) -> RecordingDTO:
46
+ return RecordingDTO(
47
+ id=str(recording.id),
48
+ workspace_id=str(recording.workspace_id),
49
+ title=recording.title,
50
+ status=recording.status,
51
+ source_type=recording.source_type,
52
+ language=recording.language,
53
+ duration_seconds=recording.duration_seconds,
54
+ segments_count=recording.segments_count,
55
+ speakers_count=recording.speakers_count,
56
+ word_count=recording.word_count,
57
+ provider_used=recording.provider_used,
58
+ transcript_storage_key=recording.transcript_storage_key,
59
+ summary=recording.summary,
60
+ created_at=recording.created_at.isoformat(),
61
+ )
62
+
63
+
64
+ def upload_session_to_dto(session) -> UploadSessionDTO:
65
+ return UploadSessionDTO(
66
+ id=str(session.id),
67
+ presigned_url=session.presigned_url,
68
+ storage_key=session.storage_key,
69
+ max_size_bytes=session.max_size_bytes,
70
+ expires_at=session.expires_at.isoformat(),
71
+ )