cjm-speaker-diarization-adapter-interface 0.0.1__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 (16) hide show
  1. cjm_speaker_diarization_adapter_interface-0.0.1/LICENSE +201 -0
  2. cjm_speaker_diarization_adapter_interface-0.0.1/PKG-INFO +49 -0
  3. cjm_speaker_diarization_adapter_interface-0.0.1/README.md +33 -0
  4. cjm_speaker_diarization_adapter_interface-0.0.1/cjm_speaker_diarization_adapter_interface/__init__.py +1 -0
  5. cjm_speaker_diarization_adapter_interface-0.0.1/cjm_speaker_diarization_adapter_interface/adapter.py +70 -0
  6. cjm_speaker_diarization_adapter_interface-0.0.1/cjm_speaker_diarization_adapter_interface/generic.py +79 -0
  7. cjm_speaker_diarization_adapter_interface-0.0.1/cjm_speaker_diarization_adapter_interface/storage.py +203 -0
  8. cjm_speaker_diarization_adapter_interface-0.0.1/cjm_speaker_diarization_adapter_interface.egg-info/PKG-INFO +49 -0
  9. cjm_speaker_diarization_adapter_interface-0.0.1/cjm_speaker_diarization_adapter_interface.egg-info/SOURCES.txt +14 -0
  10. cjm_speaker_diarization_adapter_interface-0.0.1/cjm_speaker_diarization_adapter_interface.egg-info/dependency_links.txt +1 -0
  11. cjm_speaker_diarization_adapter_interface-0.0.1/cjm_speaker_diarization_adapter_interface.egg-info/requires.txt +2 -0
  12. cjm_speaker_diarization_adapter_interface-0.0.1/cjm_speaker_diarization_adapter_interface.egg-info/top_level.txt +1 -0
  13. cjm_speaker_diarization_adapter_interface-0.0.1/pyproject.toml +29 -0
  14. cjm_speaker_diarization_adapter_interface-0.0.1/setup.cfg +4 -0
  15. cjm_speaker_diarization_adapter_interface-0.0.1/tests/test_adapter.py +87 -0
  16. cjm_speaker_diarization_adapter_interface-0.0.1/tests/test_storage.py +71 -0
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2022, fastai
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: cjm-speaker-diarization-adapter-interface
3
+ Version: 0.0.1
4
+ Summary: Typed speaker-diarization task-adapter interface — SpeakerDiarizationAdapter ABC + GenericSpeakerDiarizationAdapter (thin pass-through around a diarization tool) and the SpeakerDiarizationToolProtocol. The SpeakerDiarizationResult data noun lives in cjm-capability-primitives; turns are ANONYMOUS clusters — identity is the correction TUI's assignment lane, never this interface's job.
5
+ Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
+ License: Apache-2.0
7
+ Project-URL: Repository, https://github.com/cj-mills/cjm-speaker-diarization-adapter-interface
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: cjm-substrate>=0.0.58
14
+ Requires-Dist: cjm-capability-primitives>=0.0.12
15
+ Dynamic: license-file
16
+
17
+ # cjm-speaker-diarization-adapter-interface
18
+
19
+ <!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
20
+
21
+ Adapter interface for speaker-diarization capabilities — the speaker_diarization task-channel contract (audio → typed SpeakerTurn/SpeakerDiarizationResult) that capability libs implement and workflow cores consume; the generic adapter owns the SQLite cache bookends (content-addressed, speaker-count hints fold into the config hash).
22
+
23
+ ## Modules
24
+
25
+ - **`cjm_speaker_diarization_adapter_interface.__init__`**
26
+ - **`cjm_speaker_diarization_adapter_interface.adapter`** — The typed speaker-diarization task contract — the `SpeakerDiarizationAdapter` ABC + the `SpeakerDiarizationToolProtocol` structural contract (capability-unit Option C; session-D DECs 18d7de80 + d6df3a8e).
27
+ - **`cjm_speaker_diarization_adapter_interface.generic`** — The generic (tool-agnostic) speaker-diarization adapter — cache-check, invoke the bound tool's pure-compute `diarize`, persist. Reused across every tool capability satisfying `SpeakerDiarizationToolProtocol`, exactly as `GenericVADAdapter` is reused across VAD tools.
28
+ - **`cjm_speaker_diarization_adapter_interface.storage`** — Standardized SQLite storage for speaker-diarization results with content hashing.
29
+
30
+ ## API
31
+
32
+ ### `cjm_speaker_diarization_adapter_interface.adapter`
33
+
34
+ - `SpeakerDiarizationAdapter` _class_ — Typed speaker-diarization task adapter: source audio path in,
35
+ - `SpeakerDiarizationToolProtocol` _class_ — Structural contract for speaker-diarization tool capabilities.
36
+
37
+ ### `cjm_speaker_diarization_adapter_interface.generic`
38
+
39
+ - `GenericSpeakerDiarizationAdapter` _class_ — Generic speaker-diarization adapter: cache-check -> pure-compute tool -> persist.
40
+
41
+ ### `cjm_speaker_diarization_adapter_interface.storage`
42
+
43
+ - `SpeakerDiarizationRow` _class_ — A single row from the speaker_diarization_results table.
44
+ - `SpeakerDiarizationStorage` _class_ — Standardized SQLite storage for speaker-diarization results.
45
+
46
+ ## Dependencies
47
+
48
+ **Depends on:** `cjm-capability-primitives`, `cjm-substrate`
49
+ **Used by:** `cjm-capability-pyannote`
@@ -0,0 +1,33 @@
1
+ # cjm-speaker-diarization-adapter-interface
2
+
3
+ <!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
4
+
5
+ Adapter interface for speaker-diarization capabilities — the speaker_diarization task-channel contract (audio → typed SpeakerTurn/SpeakerDiarizationResult) that capability libs implement and workflow cores consume; the generic adapter owns the SQLite cache bookends (content-addressed, speaker-count hints fold into the config hash).
6
+
7
+ ## Modules
8
+
9
+ - **`cjm_speaker_diarization_adapter_interface.__init__`**
10
+ - **`cjm_speaker_diarization_adapter_interface.adapter`** — The typed speaker-diarization task contract — the `SpeakerDiarizationAdapter` ABC + the `SpeakerDiarizationToolProtocol` structural contract (capability-unit Option C; session-D DECs 18d7de80 + d6df3a8e).
11
+ - **`cjm_speaker_diarization_adapter_interface.generic`** — The generic (tool-agnostic) speaker-diarization adapter — cache-check, invoke the bound tool's pure-compute `diarize`, persist. Reused across every tool capability satisfying `SpeakerDiarizationToolProtocol`, exactly as `GenericVADAdapter` is reused across VAD tools.
12
+ - **`cjm_speaker_diarization_adapter_interface.storage`** — Standardized SQLite storage for speaker-diarization results with content hashing.
13
+
14
+ ## API
15
+
16
+ ### `cjm_speaker_diarization_adapter_interface.adapter`
17
+
18
+ - `SpeakerDiarizationAdapter` _class_ — Typed speaker-diarization task adapter: source audio path in,
19
+ - `SpeakerDiarizationToolProtocol` _class_ — Structural contract for speaker-diarization tool capabilities.
20
+
21
+ ### `cjm_speaker_diarization_adapter_interface.generic`
22
+
23
+ - `GenericSpeakerDiarizationAdapter` _class_ — Generic speaker-diarization adapter: cache-check -> pure-compute tool -> persist.
24
+
25
+ ### `cjm_speaker_diarization_adapter_interface.storage`
26
+
27
+ - `SpeakerDiarizationRow` _class_ — A single row from the speaker_diarization_results table.
28
+ - `SpeakerDiarizationStorage` _class_ — Standardized SQLite storage for speaker-diarization results.
29
+
30
+ ## Dependencies
31
+
32
+ **Depends on:** `cjm-capability-primitives`, `cjm-substrate`
33
+ **Used by:** `cjm-capability-pyannote`
@@ -0,0 +1,70 @@
1
+ """The typed speaker-diarization task contract — the `SpeakerDiarizationAdapter` ABC + the `SpeakerDiarizationToolProtocol` structural contract (capability-unit Option C; session-D DECs 18d7de80 + d6df3a8e)."""
2
+
3
+ from abc import abstractmethod
4
+ from typing import Any, ClassVar, Dict, Protocol, runtime_checkable
5
+
6
+ from cjm_capability_primitives.speaker_diarization import SpeakerDiarizationResult
7
+ from cjm_substrate.core.adapter import TaskAdapter
8
+
9
+
10
+ # Canonical home (PILLAR 1c): the data noun lives in cjm-capability-primitives,
11
+ # so the tool capability depends on it, never on this adapter machinery.
12
+ # SCOPE (physical-speaker principle, DEC 44afb2df): this interface carries
13
+ # DIARIZATION only — audio in, ANONYMOUS time-ranged turns out. Mapping
14
+ # clusters to named identities is the correction TUI's assignment lane
15
+ # (human judgment); assignment semantics never enter this contract.
16
+
17
+
18
+ @runtime_checkable
19
+ class SpeakerDiarizationToolProtocol(Protocol):
20
+ """Structural contract for speaker-diarization tool capabilities.
21
+
22
+ `diarize` reads the original source audio (path) and returns time-ordered
23
+ anonymous speaker turns in that audio's coordinates. `get_current_config`
24
+ supplies the effective config whose canonical hash joins downstream
25
+ identity/provenance composites."""
26
+ def diarize(self, audio: str, **kwargs) -> SpeakerDiarizationResult: ...
27
+ def get_current_config(self) -> Dict[str, Any]: ...
28
+
29
+
30
+ class SpeakerDiarizationAdapter(TaskAdapter):
31
+ """Typed speaker-diarization task adapter: source audio path in,
32
+ `SpeakerDiarizationResult` (anonymous time-ranged turns) out.
33
+
34
+ Input contract: the caller passes a path to the audio whose COORDINATES it
35
+ needs preserved — turn times always index into exactly that audio (a tool
36
+ that resamples internally must report source-coordinate times). Cluster
37
+ labels are result-scoped only.
38
+
39
+ Speaker-count hints ride kwargs (`num_speakers`, `min_speakers`,
40
+ `max_speakers`) — optional, tool-interpreted; a tool without count control
41
+ ignores them.
42
+
43
+ No cache/persist bookends in v0: the transcription workflow persists
44
+ results on its own run manifests (turns ride BESIDE transcripts,
45
+ a9cadfec), and diarization runs once per source per config — a cache DB
46
+ can land in a generic-adapter revision if re-run pressure appears.
47
+
48
+ Implementations run in-worker beside their tool capability and are
49
+ constructed with the bound tool instance: `AdapterClass(tool)`. The result
50
+ DTO is wire-registered ("speaker_diarization.result"): returned values
51
+ cross the worker boundary typed.
52
+ """
53
+
54
+ task_name: ClassVar[str] = "speaker_diarization"
55
+ required_tool_protocol: ClassVar[type] = SpeakerDiarizationToolProtocol
56
+
57
+ def __init__(
58
+ self,
59
+ tool: SpeakerDiarizationToolProtocol, # The bound tool capability instance (worker-side binding)
60
+ ):
61
+ self.tool = tool
62
+
63
+ @abstractmethod
64
+ def diarize(
65
+ self,
66
+ audio: str, # Path to the source audio to diarize (turn times index into THIS audio)
67
+ **kwargs, # Speaker-count hints + provenance + tool options
68
+ ) -> SpeakerDiarizationResult: # Time-ordered anonymous speaker turns
69
+ """Diarize audio into time-ordered anonymous speaker turns."""
70
+ ...
@@ -0,0 +1,79 @@
1
+ """The generic (tool-agnostic) speaker-diarization adapter — cache-check, invoke the bound tool's pure-compute `diarize`, persist. Reused across every tool capability satisfying `SpeakerDiarizationToolProtocol`, exactly as `GenericVADAdapter` is reused across VAD tools."""
2
+
3
+ import os
4
+
5
+ from cjm_capability_primitives.speaker_diarization import SpeakerDiarizationResult, SpeakerTurn
6
+ from cjm_substrate.core.wire import get_call_envelope
7
+ from cjm_substrate.utils.hashing import hash_dict_canonical, hash_file
8
+
9
+ from .adapter import SpeakerDiarizationAdapter
10
+ from .storage import SpeakerDiarizationStorage
11
+
12
+
13
+ class GenericSpeakerDiarizationAdapter(SpeakerDiarizationAdapter):
14
+ """Generic speaker-diarization adapter: cache-check -> pure-compute tool -> persist.
15
+
16
+ Works against ANY tool satisfying `SpeakerDiarizationToolProtocol`. The
17
+ bookends landed 2026-07-26 (the VAD-adapter precedent this class was born
18
+ citing): full-source diarization costs ~46s/hour on cuda and sources run
19
+ multi-hour, so re-runs are worth caching.
20
+
21
+ 1. cache check (file_path + file_hash + config_hash) BEFORE invoking the
22
+ tool, so a hit never loads the pipeline;
23
+ 2. the tool's pure-compute `diarize` on a miss / forced call;
24
+ 3. `save_with_logging` (upsert by file_path + config_hash).
25
+
26
+ Unlike VAD, `diarize` kwargs are NOT provenance pass-through: the
27
+ speaker-count hints (num_speakers / min_speakers / max_speakers) steer the
28
+ clustering, so they FOLD INTO `config_hash` — the same audio diarized
29
+ under a different hint is a different cache row. `force` rides
30
+ `CallEnvelope.control` (not a task kwarg, keeping `diarize(audio)` pure).
31
+ Storage lives at `<CAPABILITY_DATA_DIR>/speaker_diarization.db`; the
32
+ substrate injects the per-capability `CAPABILITY_DATA_DIR` at spawn.
33
+ """
34
+
35
+ # Per-call kwargs that change the clustering output — cache identity,
36
+ # mirrored from the pyannote tool's diarize signature.
37
+ HINT_KEYS = ("num_speakers", "min_speakers", "max_speakers")
38
+
39
+ def _get_storage(self) -> SpeakerDiarizationStorage:
40
+ """Lazily open the per-capability cache DB under the substrate-injected
41
+ CAPABILITY_DATA_DIR (created at worker spawn)."""
42
+ if getattr(self, "_storage", None) is None:
43
+ db_path = os.path.join(os.environ["CAPABILITY_DATA_DIR"], "speaker_diarization.db")
44
+ self._storage = SpeakerDiarizationStorage(db_path)
45
+ return self._storage
46
+
47
+ def diarize(
48
+ self,
49
+ audio: str, # Path to the source audio to diarize (decoded PCM rendition)
50
+ **kwargs, # Speaker-count hints (cache identity) + provenance + tool options
51
+ ) -> SpeakerDiarizationResult: # Time-ordered anonymous speaker turns
52
+ """Cache-check, invoke the bound tool's pure-compute `diarize`, persist."""
53
+ file_path = str(audio)
54
+ file_hash = hash_file(file_path)
55
+ hints = {k: kwargs[k] for k in self.HINT_KEYS if kwargs.get(k) is not None}
56
+ config_hash = hash_dict_canonical({**self.tool.get_current_config(), **hints})
57
+
58
+ env = get_call_envelope()
59
+ force = bool(env.control.get("force")) if env is not None else False
60
+
61
+ storage = self._get_storage()
62
+ if not force:
63
+ cached = storage.get_cached(file_path, file_hash, config_hash)
64
+ if cached is not None:
65
+ return SpeakerDiarizationResult(
66
+ turns=[SpeakerTurn(**t) for t in (cached.turns or [])],
67
+ metadata=cached.metadata or {},
68
+ )
69
+
70
+ # Pure compute on the tool (cache miss / forced).
71
+ result = self.tool.diarize(audio, **kwargs)
72
+
73
+ # Persist. Content-addressed row; cache key is (file_path, config_hash)
74
+ # with file_hash for content-correctness.
75
+ storage.save_with_logging(
76
+ file_path=file_path, file_hash=file_hash, config_hash=config_hash,
77
+ turns=[t.to_dict() for t in result.turns], metadata=result.metadata,
78
+ )
79
+ return result
@@ -0,0 +1,203 @@
1
+ """Standardized SQLite storage for speaker-diarization results with content hashing.
2
+
3
+ Cache shape follows the VAD-adapter precedent: content-addressed rows upserted
4
+ by (file_path, config_hash) with file_hash for content-correctness. Landed
5
+ 2026-07-26 — full-source diarization (~46s/hour on cuda, multi-hour sources)
6
+ made re-runs worth caching, unlike the pysbd-precedent no-cache assessment
7
+ this pair was born under."""
8
+
9
+ import json
10
+ import logging
11
+ import sqlite3
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from cjm_substrate.core.journal_store import SubstrateEventType
17
+ from cjm_substrate.core.wire import record_account
18
+ from cjm_substrate.utils.hashing import hash_file
19
+
20
+
21
+ # CR-14 follow-up: storage events are substrate-family accounts. The helpers
22
+ # below RECORD them (no-op outside a worker call span); the host journals
23
+ # them off the response header with worker_reported=True.
24
+
25
+
26
+ @dataclass
27
+ class SpeakerDiarizationRow:
28
+ """A single row from the speaker_diarization_results table."""
29
+ file_path: str # Path to the analyzed (decoded PCM rendition) audio file
30
+ file_hash: str # Hash of the analyzed file in "algo:hexdigest" format
31
+ config_hash: str # Hash of the diarization config (tool config + speaker-count hints)
32
+ turns: Optional[List[Dict[str, Any]]] = None # Diarized speech turns (serialized SpeakerTurns)
33
+ metadata: Optional[Dict[str, Any]] = None # Diarization metadata
34
+ created_at: Optional[float] = None # Unix timestamp
35
+
36
+
37
+ class SpeakerDiarizationStorage:
38
+ """Standardized SQLite storage for speaker-diarization results."""
39
+
40
+ SCHEMA = """
41
+ CREATE TABLE IF NOT EXISTS speaker_diarization_results (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ file_path TEXT NOT NULL,
44
+ file_hash TEXT NOT NULL,
45
+ config_hash TEXT NOT NULL,
46
+ turns JSON,
47
+ metadata JSON,
48
+ created_at REAL NOT NULL,
49
+ UNIQUE(file_path, config_hash)
50
+ )
51
+ """
52
+
53
+ INDEX = "CREATE INDEX IF NOT EXISTS idx_speaker_diarization_file_path ON speaker_diarization_results(file_path);"
54
+
55
+ def __init__(
56
+ self,
57
+ db_path: str # Absolute path to the SQLite database file
58
+ ):
59
+ """Initialize storage and create table if needed."""
60
+ self.db_path = db_path
61
+ with sqlite3.connect(self.db_path) as con:
62
+ con.execute(self.SCHEMA)
63
+ con.execute(self.INDEX)
64
+
65
+ def save(
66
+ self,
67
+ file_path: str, # Path to the analyzed audio file
68
+ file_hash: str, # Hash of the analyzed file in "algo:hexdigest" format
69
+ config_hash: str, # Hash of the diarization config (tool config + hints)
70
+ turns: Optional[List[Dict[str, Any]]] = None, # Diarized speech turns
71
+ metadata: Optional[Dict[str, Any]] = None # Diarization metadata
72
+ ) -> None:
73
+ """Save or replace a diarization result (upsert by file_path + config_hash)."""
74
+ with sqlite3.connect(self.db_path) as con:
75
+ con.execute(
76
+ """INSERT OR REPLACE INTO speaker_diarization_results
77
+ (file_path, file_hash, config_hash, turns, metadata, created_at)
78
+ VALUES (?, ?, ?, ?, ?, ?)""",
79
+ (
80
+ file_path,
81
+ file_hash,
82
+ config_hash,
83
+ json.dumps(turns) if turns else None,
84
+ json.dumps(metadata) if metadata else None,
85
+ time.time()
86
+ )
87
+ )
88
+
89
+ def save_with_logging(
90
+ self,
91
+ *,
92
+ file_path: str, # Path to the analyzed audio file
93
+ file_hash: str, # Hash of the analyzed file in "algo:hexdigest" format
94
+ config_hash: str, # Hash of the diarization config (tool config + hints)
95
+ turns: Optional[List[Dict[str, Any]]] = None, # Diarized speech turns
96
+ metadata: Optional[Dict[str, Any]] = None, # Diarization metadata
97
+ logger: Optional[logging.Logger] = None # Optional logger for success/failure messages
98
+ ) -> bool: # True if saved; False if the save failed (error logged, not raised)
99
+ """Save a result, logging success/failure. Failures are logged and swallowed (returns False).
100
+
101
+ CR-14 follow-up: records a RESULT_SAVED account either way (ok flag +
102
+ file/config references — the journal never carries content) so saves AND
103
+ swallowed save-failures become auditable journal rows."""
104
+ try:
105
+ self.save(
106
+ file_path=file_path,
107
+ file_hash=file_hash,
108
+ config_hash=config_hash,
109
+ turns=turns,
110
+ metadata=metadata,
111
+ )
112
+ record_account(SubstrateEventType.RESULT_SAVED.value, {
113
+ "ok": True, "file_path": file_path, "file_hash": file_hash,
114
+ "config_hash": config_hash,
115
+ })
116
+ if logger:
117
+ logger.info(f"Saved diarization result to DB ({file_path})")
118
+ return True
119
+ except Exception as e:
120
+ record_account(SubstrateEventType.RESULT_SAVED.value, {
121
+ "ok": False, "file_path": file_path, "file_hash": file_hash,
122
+ "config_hash": config_hash, "error": str(e),
123
+ })
124
+ if logger:
125
+ logger.error(f"Failed to save to DB: {e}")
126
+ return False
127
+
128
+ def get_cached(
129
+ self,
130
+ file_path: str, # Path to the audio file
131
+ file_hash: str, # Content hash of the file (cache miss if the file changed)
132
+ config_hash: str # Config hash to match
133
+ ) -> Optional[SpeakerDiarizationRow]: # Cached row or None
134
+ """Retrieve a content-correct cached diarization result.
135
+
136
+ Matches on file_path + file_hash + config_hash, so a changed file (new
137
+ file_hash) misses the cache even though a stale row may still exist at the
138
+ same (file_path, config_hash) — the next save() replaces it.
139
+
140
+ CR-14 follow-up: a hit records a CACHE_HIT account (the cache-serving
141
+ decision is an account-of-action)."""
142
+ with sqlite3.connect(self.db_path) as con:
143
+ cur = con.execute(
144
+ """SELECT file_path, file_hash, config_hash, turns, metadata, created_at
145
+ FROM speaker_diarization_results
146
+ WHERE file_path = ? AND file_hash = ? AND config_hash = ?""",
147
+ (file_path, file_hash, config_hash)
148
+ )
149
+ row = cur.fetchone()
150
+ if not row:
151
+ return None
152
+ record_account(SubstrateEventType.CACHE_HIT.value, {
153
+ "file_path": file_path, "file_hash": file_hash,
154
+ "config_hash": config_hash,
155
+ })
156
+ return SpeakerDiarizationRow(
157
+ file_path=row[0],
158
+ file_hash=row[1],
159
+ config_hash=row[2],
160
+ turns=json.loads(row[3]) if row[3] else None,
161
+ metadata=json.loads(row[4]) if row[4] else None,
162
+ created_at=row[5]
163
+ )
164
+
165
+ def list_jobs(
166
+ self,
167
+ limit: int = 100 # Maximum number of rows to return
168
+ ) -> List[SpeakerDiarizationRow]: # List of diarization rows
169
+ """List diarization results ordered by creation time (newest first)."""
170
+ results = []
171
+ with sqlite3.connect(self.db_path) as con:
172
+ cur = con.execute(
173
+ """SELECT file_path, file_hash, config_hash, turns, metadata, created_at
174
+ FROM speaker_diarization_results ORDER BY created_at DESC LIMIT ?""",
175
+ (limit,)
176
+ )
177
+ for row in cur:
178
+ results.append(SpeakerDiarizationRow(
179
+ file_path=row[0],
180
+ file_hash=row[1],
181
+ config_hash=row[2],
182
+ turns=json.loads(row[3]) if row[3] else None,
183
+ metadata=json.loads(row[4]) if row[4] else None,
184
+ created_at=row[5]
185
+ ))
186
+ return results
187
+
188
+ def verify_file(
189
+ self,
190
+ file_path: str, # Path to the audio file
191
+ config_hash: str # Config hash to look up
192
+ ) -> Optional[bool]: # True if file matches, False if changed, None if not found
193
+ """Verify the analyzed file still matches the hash stored for (file_path, config_hash)."""
194
+ with sqlite3.connect(self.db_path) as con:
195
+ cur = con.execute(
196
+ """SELECT file_hash FROM speaker_diarization_results
197
+ WHERE file_path = ? AND config_hash = ?""",
198
+ (file_path, config_hash)
199
+ )
200
+ row = cur.fetchone()
201
+ if not row:
202
+ return None
203
+ return hash_file(file_path) == row[0]
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: cjm-speaker-diarization-adapter-interface
3
+ Version: 0.0.1
4
+ Summary: Typed speaker-diarization task-adapter interface — SpeakerDiarizationAdapter ABC + GenericSpeakerDiarizationAdapter (thin pass-through around a diarization tool) and the SpeakerDiarizationToolProtocol. The SpeakerDiarizationResult data noun lives in cjm-capability-primitives; turns are ANONYMOUS clusters — identity is the correction TUI's assignment lane, never this interface's job.
5
+ Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
+ License: Apache-2.0
7
+ Project-URL: Repository, https://github.com/cj-mills/cjm-speaker-diarization-adapter-interface
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: cjm-substrate>=0.0.58
14
+ Requires-Dist: cjm-capability-primitives>=0.0.12
15
+ Dynamic: license-file
16
+
17
+ # cjm-speaker-diarization-adapter-interface
18
+
19
+ <!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
20
+
21
+ Adapter interface for speaker-diarization capabilities — the speaker_diarization task-channel contract (audio → typed SpeakerTurn/SpeakerDiarizationResult) that capability libs implement and workflow cores consume; the generic adapter owns the SQLite cache bookends (content-addressed, speaker-count hints fold into the config hash).
22
+
23
+ ## Modules
24
+
25
+ - **`cjm_speaker_diarization_adapter_interface.__init__`**
26
+ - **`cjm_speaker_diarization_adapter_interface.adapter`** — The typed speaker-diarization task contract — the `SpeakerDiarizationAdapter` ABC + the `SpeakerDiarizationToolProtocol` structural contract (capability-unit Option C; session-D DECs 18d7de80 + d6df3a8e).
27
+ - **`cjm_speaker_diarization_adapter_interface.generic`** — The generic (tool-agnostic) speaker-diarization adapter — cache-check, invoke the bound tool's pure-compute `diarize`, persist. Reused across every tool capability satisfying `SpeakerDiarizationToolProtocol`, exactly as `GenericVADAdapter` is reused across VAD tools.
28
+ - **`cjm_speaker_diarization_adapter_interface.storage`** — Standardized SQLite storage for speaker-diarization results with content hashing.
29
+
30
+ ## API
31
+
32
+ ### `cjm_speaker_diarization_adapter_interface.adapter`
33
+
34
+ - `SpeakerDiarizationAdapter` _class_ — Typed speaker-diarization task adapter: source audio path in,
35
+ - `SpeakerDiarizationToolProtocol` _class_ — Structural contract for speaker-diarization tool capabilities.
36
+
37
+ ### `cjm_speaker_diarization_adapter_interface.generic`
38
+
39
+ - `GenericSpeakerDiarizationAdapter` _class_ — Generic speaker-diarization adapter: cache-check -> pure-compute tool -> persist.
40
+
41
+ ### `cjm_speaker_diarization_adapter_interface.storage`
42
+
43
+ - `SpeakerDiarizationRow` _class_ — A single row from the speaker_diarization_results table.
44
+ - `SpeakerDiarizationStorage` _class_ — Standardized SQLite storage for speaker-diarization results.
45
+
46
+ ## Dependencies
47
+
48
+ **Depends on:** `cjm-capability-primitives`, `cjm-substrate`
49
+ **Used by:** `cjm-capability-pyannote`
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ cjm_speaker_diarization_adapter_interface/__init__.py
5
+ cjm_speaker_diarization_adapter_interface/adapter.py
6
+ cjm_speaker_diarization_adapter_interface/generic.py
7
+ cjm_speaker_diarization_adapter_interface/storage.py
8
+ cjm_speaker_diarization_adapter_interface.egg-info/PKG-INFO
9
+ cjm_speaker_diarization_adapter_interface.egg-info/SOURCES.txt
10
+ cjm_speaker_diarization_adapter_interface.egg-info/dependency_links.txt
11
+ cjm_speaker_diarization_adapter_interface.egg-info/requires.txt
12
+ cjm_speaker_diarization_adapter_interface.egg-info/top_level.txt
13
+ tests/test_adapter.py
14
+ tests/test_storage.py
@@ -0,0 +1,2 @@
1
+ cjm-substrate>=0.0.58
2
+ cjm-capability-primitives>=0.0.12
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cjm-speaker-diarization-adapter-interface"
7
+ dynamic = ["version"]
8
+ description = "Typed speaker-diarization task-adapter interface — SpeakerDiarizationAdapter ABC + GenericSpeakerDiarizationAdapter (thin pass-through around a diarization tool) and the SpeakerDiarizationToolProtocol. The SpeakerDiarizationResult data noun lives in cjm-capability-primitives; turns are ANONYMOUS clusters — identity is the correction TUI's assignment lane, never this interface's job."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [{name = "Christian J. Mills", email = "9126128+cj-mills@users.noreply.github.com"}]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ ]
17
+ dependencies = [
18
+ "cjm-substrate>=0.0.58",
19
+ "cjm-capability-primitives>=0.0.12",
20
+ ]
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/cj-mills/cjm-speaker-diarization-adapter-interface"
24
+
25
+ [tool.setuptools.dynamic]
26
+ version = {attr = "cjm_speaker_diarization_adapter_interface.__version__"}
27
+
28
+ [tool.setuptools.packages.find]
29
+ include = ["cjm_speaker_diarization_adapter_interface*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,87 @@
1
+ """Tests for the speaker-diarization adapter interface — protocol conformance + the generic pass-through."""
2
+ from typing import Any, Dict
3
+
4
+ from cjm_capability_primitives.speaker_diarization import (SpeakerDiarizationResult,
5
+ SpeakerTurn)
6
+ from cjm_speaker_diarization_adapter_interface.adapter import (
7
+ SpeakerDiarizationAdapter, SpeakerDiarizationToolProtocol)
8
+ from cjm_speaker_diarization_adapter_interface.generic import (
9
+ GenericSpeakerDiarizationAdapter)
10
+
11
+
12
+ class FakeDiarizerTool:
13
+ """Minimal tool satisfying the structural protocol: two fixed turns,
14
+ honoring a num_speakers=1 hint by merging them."""
15
+
16
+ def __init__(self):
17
+ self.calls = 0
18
+ self.last_kwargs: Dict[str, Any] = {}
19
+
20
+ def diarize(self, audio: str, **kwargs) -> SpeakerDiarizationResult:
21
+ self.calls += 1
22
+ self.last_kwargs = dict(kwargs)
23
+ if kwargs.get("num_speakers") == 1:
24
+ turns = [SpeakerTurn(start=0.2, end=3.9, speaker="SPEAKER_00")]
25
+ else:
26
+ turns = [SpeakerTurn(start=0.2, end=1.5, speaker="SPEAKER_00"),
27
+ SpeakerTurn(start=1.8, end=3.9, speaker="SPEAKER_01")]
28
+ return SpeakerDiarizationResult(
29
+ turns=turns, metadata={"speaker_count": len({t.speaker for t in turns}),
30
+ "audio": audio})
31
+
32
+ def get_current_config(self) -> Dict[str, Any]:
33
+ return {"model_id": "fake-diarizer"}
34
+
35
+
36
+ def test_fake_tool_satisfies_protocol():
37
+ assert isinstance(FakeDiarizerTool(), SpeakerDiarizationToolProtocol)
38
+
39
+
40
+ def test_adapter_declares_task_contract():
41
+ assert SpeakerDiarizationAdapter.task_name == "speaker_diarization"
42
+ assert (SpeakerDiarizationAdapter.required_tool_protocol
43
+ is SpeakerDiarizationToolProtocol)
44
+
45
+
46
+ def test_generic_adapter_passes_through_with_hints(tmp_path, monkeypatch):
47
+ monkeypatch.setenv("CAPABILITY_DATA_DIR", str(tmp_path))
48
+ audio = tmp_path / "source.wav"
49
+ audio.write_bytes(b"fake-pcm-bytes")
50
+ tool = FakeDiarizerTool()
51
+ adapter = GenericSpeakerDiarizationAdapter(tool)
52
+ result = adapter.diarize(str(audio))
53
+ assert tool.calls == 1
54
+ assert [t.speaker for t in result.turns] == ["SPEAKER_00", "SPEAKER_01"]
55
+ assert result.metadata["audio"] == str(audio)
56
+ # speaker-count hints ride kwargs untouched — and fold into the cache
57
+ # key, so the hinted call is a MISS, not a replay of the unhinted row
58
+ hinted = adapter.diarize(str(audio), num_speakers=1)
59
+ assert tool.calls == 2
60
+ assert tool.last_kwargs == {"num_speakers": 1}
61
+ assert len(hinted.turns) == 1 and hinted.metadata["speaker_count"] == 1
62
+
63
+
64
+ def test_generic_adapter_cache_bookends(tmp_path, monkeypatch):
65
+ monkeypatch.setenv("CAPABILITY_DATA_DIR", str(tmp_path))
66
+ audio = tmp_path / "source.wav"
67
+ audio.write_bytes(b"fake-pcm-bytes")
68
+ tool = FakeDiarizerTool()
69
+ adapter = GenericSpeakerDiarizationAdapter(tool)
70
+ first = adapter.diarize(str(audio))
71
+ assert tool.calls == 1
72
+ # same audio + same config -> served from cache, tool NOT re-invoked;
73
+ # turns come back typed
74
+ replay = adapter.diarize(str(audio))
75
+ assert tool.calls == 1
76
+ assert isinstance(replay.turns[0], SpeakerTurn)
77
+ assert ([(t.start, t.end, t.speaker) for t in replay.turns]
78
+ == [(t.start, t.end, t.speaker) for t in first.turns])
79
+ # a hinted call is a DIFFERENT cache row; replaying the same hint hits it
80
+ adapter.diarize(str(audio), num_speakers=1)
81
+ assert tool.calls == 2
82
+ adapter.diarize(str(audio), num_speakers=1)
83
+ assert tool.calls == 2
84
+ # content change at the same path -> new file_hash -> miss
85
+ audio.write_bytes(b"different-pcm-bytes")
86
+ adapter.diarize(str(audio))
87
+ assert tool.calls == 3
@@ -0,0 +1,71 @@
1
+ """Tests for the speaker-diarization storage layer — content-correct cache semantics on the VAD-adapter precedent."""
2
+
3
+ from cjm_substrate.utils.hashing import hash_file
4
+ from cjm_speaker_diarization_adapter_interface.storage import (
5
+ SpeakerDiarizationRow, SpeakerDiarizationStorage)
6
+
7
+
8
+ def _seeded_storage(tmp_path):
9
+ """Storage with one saved result for a real (tiny) audio file."""
10
+ storage = SpeakerDiarizationStorage(str(tmp_path / "speaker_diarization.db"))
11
+ audio = tmp_path / "a.wav"
12
+ audio.write_bytes(b"pcm-one")
13
+ turns = [{"start": 0.2, "end": 1.5, "speaker": "SPEAKER_00", "confidence": None},
14
+ {"start": 1.8, "end": 3.9, "speaker": "SPEAKER_01", "confidence": 0.9}]
15
+ storage.save(file_path=str(audio), file_hash="sha256:aaa", config_hash="cfg1",
16
+ turns=turns, metadata={"speaker_count": 2})
17
+ return storage, str(audio), turns
18
+
19
+
20
+ def test_save_get_cached_and_content_correctness(tmp_path):
21
+ storage, audio, turns = _seeded_storage(tmp_path)
22
+ row = storage.get_cached(audio, "sha256:aaa", "cfg1")
23
+ assert isinstance(row, SpeakerDiarizationRow)
24
+ assert row.turns == turns
25
+ assert row.metadata == {"speaker_count": 2}
26
+ assert row.created_at is not None
27
+ # changed content (new file_hash) misses even though a row exists at
28
+ # the same (file_path, config_hash)
29
+ assert storage.get_cached(audio, "sha256:bbb", "cfg1") is None
30
+ # unknown config misses
31
+ assert storage.get_cached(audio, "sha256:aaa", "cfg2") is None
32
+
33
+
34
+ def test_insert_or_replace_on_cache_key(tmp_path):
35
+ storage, audio, _ = _seeded_storage(tmp_path)
36
+ storage.save(file_path=audio, file_hash="sha256:bbb", config_hash="cfg1",
37
+ turns=[{"start": 0.0, "end": 9.9, "speaker": "SPEAKER_00",
38
+ "confidence": None}])
39
+ # upsert replaced the (file_path, cfg1) row — no duplicate
40
+ assert len(storage.list_jobs()) == 1
41
+ assert storage.get_cached(audio, "sha256:aaa", "cfg1") is None
42
+ row = storage.get_cached(audio, "sha256:bbb", "cfg1")
43
+ assert row.turns[0]["end"] == 9.9
44
+ assert row.metadata is None
45
+
46
+
47
+ def test_save_with_logging_success_and_swallowed_failure(tmp_path):
48
+ storage, audio, turns = _seeded_storage(tmp_path)
49
+ assert storage.save_with_logging(file_path=audio, file_hash="sha256:aaa",
50
+ config_hash="cfg2", turns=turns) is True
51
+
52
+ def _boom(*args, **kwargs):
53
+ raise RuntimeError("disk full")
54
+
55
+ storage.save = _boom # instance attribute shadows the method
56
+ assert storage.save_with_logging(file_path=audio, file_hash="x",
57
+ config_hash="cfg3") is False
58
+
59
+
60
+ def test_list_jobs_and_verify_file(tmp_path):
61
+ storage, audio, _ = _seeded_storage(tmp_path)
62
+ storage.save(file_path=audio, file_hash="h", config_hash="cfg2")
63
+ rows = storage.list_jobs()
64
+ assert len(rows) == 2
65
+ assert rows[0].created_at >= rows[1].created_at # newest first
66
+ # verify_file compares the stored hash against the file's REAL hash
67
+ storage.save(file_path=audio, file_hash=hash_file(audio),
68
+ config_hash="cfg-real")
69
+ assert storage.verify_file(audio, "cfg-real") is True
70
+ assert storage.verify_file(audio, "cfg1") is False # seeded fake hash
71
+ assert storage.verify_file(audio, "nope") is None