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.
- stapel_recordings-0.1.0/LICENSE +21 -0
- stapel_recordings-0.1.0/PKG-INFO +110 -0
- stapel_recordings-0.1.0/README.md +91 -0
- stapel_recordings-0.1.0/__init__.py +31 -0
- stapel_recordings-0.1.0/actions.py +51 -0
- stapel_recordings-0.1.0/admin.py +43 -0
- stapel_recordings-0.1.0/apps.py +27 -0
- stapel_recordings-0.1.0/checks.py +66 -0
- stapel_recordings-0.1.0/conf.py +72 -0
- stapel_recordings-0.1.0/conftest.py +78 -0
- stapel_recordings-0.1.0/dto.py +71 -0
- stapel_recordings-0.1.0/errors.py +25 -0
- stapel_recordings-0.1.0/events.py +112 -0
- stapel_recordings-0.1.0/gdpr.py +66 -0
- stapel_recordings-0.1.0/management/__init__.py +0 -0
- stapel_recordings-0.1.0/management/commands/__init__.py +0 -0
- stapel_recordings-0.1.0/management/commands/recordings_reconcile.py +98 -0
- stapel_recordings-0.1.0/migrations/0001_initial.py +177 -0
- stapel_recordings-0.1.0/migrations/__init__.py +0 -0
- stapel_recordings-0.1.0/models.py +266 -0
- stapel_recordings-0.1.0/normalize.py +114 -0
- stapel_recordings-0.1.0/pipeline.py +211 -0
- stapel_recordings-0.1.0/py.typed +0 -0
- stapel_recordings-0.1.0/pyproject.toml +51 -0
- stapel_recordings-0.1.0/schemas/emits/recording.completed.json +18 -0
- stapel_recordings-0.1.0/schemas/emits/recording.failed.json +15 -0
- stapel_recordings-0.1.0/schemas/emits/recording.stage.json +12 -0
- stapel_recordings-0.1.0/schemas/emits/recording.stage_completed.json +15 -0
- stapel_recordings-0.1.0/schemas/emits/recording.uploaded.json +16 -0
- stapel_recordings-0.1.0/serializers.py +34 -0
- stapel_recordings-0.1.0/services.py +125 -0
- stapel_recordings-0.1.0/setup.cfg +4 -0
- stapel_recordings-0.1.0/stages.py +450 -0
- stapel_recordings-0.1.0/stapel_recordings.egg-info/PKG-INFO +110 -0
- stapel_recordings-0.1.0/stapel_recordings.egg-info/SOURCES.txt +108 -0
- stapel_recordings-0.1.0/stapel_recordings.egg-info/dependency_links.txt +1 -0
- stapel_recordings-0.1.0/stapel_recordings.egg-info/requires.txt +7 -0
- stapel_recordings-0.1.0/stapel_recordings.egg-info/top_level.txt +1 -0
- stapel_recordings-0.1.0/storage.py +332 -0
- stapel_recordings-0.1.0/tests/__init__.py +0 -0
- stapel_recordings-0.1.0/tests/conftest.py +152 -0
- stapel_recordings-0.1.0/tests/fakes.py +108 -0
- stapel_recordings-0.1.0/tests/test_api.py +61 -0
- stapel_recordings-0.1.0/tests/test_checks.py +30 -0
- stapel_recordings-0.1.0/tests/test_gdpr.py +59 -0
- stapel_recordings-0.1.0/tests/test_models.py +53 -0
- stapel_recordings-0.1.0/tests/test_pipeline.py +89 -0
- stapel_recordings-0.1.0/tests/test_pipeline_extension.py +120 -0
- stapel_recordings-0.1.0/tests/test_public_api.py +46 -0
- stapel_recordings-0.1.0/tests/test_reconcile.py +55 -0
- stapel_recordings-0.1.0/tests/test_retry_dlq.py +60 -0
- stapel_recordings-0.1.0/tests/test_schemas.py +35 -0
- stapel_recordings-0.1.0/tests/test_storage_seam.py +64 -0
- stapel_recordings-0.1.0/tests/test_summarize.py +47 -0
- stapel_recordings-0.1.0/tests/test_transcribe.py +111 -0
- stapel_recordings-0.1.0/tests/test_upload.py +55 -0
- stapel_recordings-0.1.0/tests/urls.py +5 -0
- stapel_recordings-0.1.0/transcript_schema.py +318 -0
- stapel_recordings-0.1.0/urls.py +13 -0
- 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
|
+
)
|