audex 1.0.7a3__py3-none-any.whl

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 (192) hide show
  1. audex/__init__.py +9 -0
  2. audex/__main__.py +7 -0
  3. audex/cli/__init__.py +189 -0
  4. audex/cli/apis/__init__.py +12 -0
  5. audex/cli/apis/init/__init__.py +34 -0
  6. audex/cli/apis/init/gencfg.py +130 -0
  7. audex/cli/apis/init/setup.py +330 -0
  8. audex/cli/apis/init/vprgroup.py +125 -0
  9. audex/cli/apis/serve.py +141 -0
  10. audex/cli/args.py +356 -0
  11. audex/cli/exceptions.py +44 -0
  12. audex/cli/helper/__init__.py +0 -0
  13. audex/cli/helper/ansi.py +193 -0
  14. audex/cli/helper/display.py +288 -0
  15. audex/config/__init__.py +64 -0
  16. audex/config/core/__init__.py +30 -0
  17. audex/config/core/app.py +29 -0
  18. audex/config/core/audio.py +45 -0
  19. audex/config/core/logging.py +163 -0
  20. audex/config/core/session.py +11 -0
  21. audex/config/helper/__init__.py +1 -0
  22. audex/config/helper/client/__init__.py +1 -0
  23. audex/config/helper/client/http.py +28 -0
  24. audex/config/helper/client/websocket.py +21 -0
  25. audex/config/helper/provider/__init__.py +1 -0
  26. audex/config/helper/provider/dashscope.py +13 -0
  27. audex/config/helper/provider/unisound.py +18 -0
  28. audex/config/helper/provider/xfyun.py +23 -0
  29. audex/config/infrastructure/__init__.py +31 -0
  30. audex/config/infrastructure/cache.py +51 -0
  31. audex/config/infrastructure/database.py +48 -0
  32. audex/config/infrastructure/recorder.py +32 -0
  33. audex/config/infrastructure/store.py +19 -0
  34. audex/config/provider/__init__.py +18 -0
  35. audex/config/provider/transcription.py +109 -0
  36. audex/config/provider/vpr.py +99 -0
  37. audex/container.py +40 -0
  38. audex/entity/__init__.py +468 -0
  39. audex/entity/doctor.py +109 -0
  40. audex/entity/doctor.pyi +51 -0
  41. audex/entity/fields.py +401 -0
  42. audex/entity/segment.py +115 -0
  43. audex/entity/segment.pyi +38 -0
  44. audex/entity/session.py +133 -0
  45. audex/entity/session.pyi +47 -0
  46. audex/entity/utterance.py +142 -0
  47. audex/entity/utterance.pyi +48 -0
  48. audex/entity/vp.py +68 -0
  49. audex/entity/vp.pyi +35 -0
  50. audex/exceptions.py +157 -0
  51. audex/filters/__init__.py +692 -0
  52. audex/filters/generated/__init__.py +21 -0
  53. audex/filters/generated/doctor.py +987 -0
  54. audex/filters/generated/segment.py +723 -0
  55. audex/filters/generated/session.py +978 -0
  56. audex/filters/generated/utterance.py +939 -0
  57. audex/filters/generated/vp.py +815 -0
  58. audex/helper/__init__.py +1 -0
  59. audex/helper/hash.py +33 -0
  60. audex/helper/mixin.py +65 -0
  61. audex/helper/net.py +19 -0
  62. audex/helper/settings/__init__.py +830 -0
  63. audex/helper/settings/fields.py +317 -0
  64. audex/helper/stream.py +153 -0
  65. audex/injectors/__init__.py +1 -0
  66. audex/injectors/config.py +12 -0
  67. audex/injectors/lifespan.py +7 -0
  68. audex/lib/__init__.py +1 -0
  69. audex/lib/cache/__init__.py +383 -0
  70. audex/lib/cache/inmemory.py +513 -0
  71. audex/lib/database/__init__.py +83 -0
  72. audex/lib/database/sqlite.py +406 -0
  73. audex/lib/exporter.py +189 -0
  74. audex/lib/injectors/__init__.py +1 -0
  75. audex/lib/injectors/cache.py +25 -0
  76. audex/lib/injectors/container.py +47 -0
  77. audex/lib/injectors/exporter.py +26 -0
  78. audex/lib/injectors/recorder.py +33 -0
  79. audex/lib/injectors/server.py +17 -0
  80. audex/lib/injectors/session.py +18 -0
  81. audex/lib/injectors/sqlite.py +24 -0
  82. audex/lib/injectors/store.py +13 -0
  83. audex/lib/injectors/transcription.py +42 -0
  84. audex/lib/injectors/usb.py +12 -0
  85. audex/lib/injectors/vpr.py +65 -0
  86. audex/lib/injectors/wifi.py +7 -0
  87. audex/lib/recorder.py +844 -0
  88. audex/lib/repos/__init__.py +149 -0
  89. audex/lib/repos/container.py +23 -0
  90. audex/lib/repos/database/__init__.py +1 -0
  91. audex/lib/repos/database/sqlite.py +672 -0
  92. audex/lib/repos/decorators.py +74 -0
  93. audex/lib/repos/doctor.py +286 -0
  94. audex/lib/repos/segment.py +302 -0
  95. audex/lib/repos/session.py +285 -0
  96. audex/lib/repos/tables/__init__.py +70 -0
  97. audex/lib/repos/tables/doctor.py +137 -0
  98. audex/lib/repos/tables/segment.py +113 -0
  99. audex/lib/repos/tables/session.py +140 -0
  100. audex/lib/repos/tables/utterance.py +131 -0
  101. audex/lib/repos/tables/vp.py +102 -0
  102. audex/lib/repos/utterance.py +288 -0
  103. audex/lib/repos/vp.py +286 -0
  104. audex/lib/restful.py +251 -0
  105. audex/lib/server/__init__.py +97 -0
  106. audex/lib/server/auth.py +98 -0
  107. audex/lib/server/handlers.py +248 -0
  108. audex/lib/server/templates/index.html.j2 +226 -0
  109. audex/lib/server/templates/login.html.j2 +111 -0
  110. audex/lib/server/templates/static/script.js +68 -0
  111. audex/lib/server/templates/static/style.css +579 -0
  112. audex/lib/server/types.py +123 -0
  113. audex/lib/session.py +503 -0
  114. audex/lib/store/__init__.py +238 -0
  115. audex/lib/store/localfile.py +411 -0
  116. audex/lib/transcription/__init__.py +33 -0
  117. audex/lib/transcription/dashscope.py +525 -0
  118. audex/lib/transcription/events.py +62 -0
  119. audex/lib/usb.py +554 -0
  120. audex/lib/vpr/__init__.py +38 -0
  121. audex/lib/vpr/unisound/__init__.py +185 -0
  122. audex/lib/vpr/unisound/types.py +469 -0
  123. audex/lib/vpr/xfyun/__init__.py +483 -0
  124. audex/lib/vpr/xfyun/types.py +679 -0
  125. audex/lib/websocket/__init__.py +8 -0
  126. audex/lib/websocket/connection.py +485 -0
  127. audex/lib/websocket/pool.py +991 -0
  128. audex/lib/wifi.py +1146 -0
  129. audex/lifespan.py +75 -0
  130. audex/service/__init__.py +27 -0
  131. audex/service/decorators.py +73 -0
  132. audex/service/doctor/__init__.py +652 -0
  133. audex/service/doctor/const.py +36 -0
  134. audex/service/doctor/exceptions.py +96 -0
  135. audex/service/doctor/types.py +54 -0
  136. audex/service/export/__init__.py +236 -0
  137. audex/service/export/const.py +17 -0
  138. audex/service/export/exceptions.py +34 -0
  139. audex/service/export/types.py +21 -0
  140. audex/service/injectors/__init__.py +1 -0
  141. audex/service/injectors/container.py +53 -0
  142. audex/service/injectors/doctor.py +34 -0
  143. audex/service/injectors/export.py +27 -0
  144. audex/service/injectors/session.py +49 -0
  145. audex/service/session/__init__.py +754 -0
  146. audex/service/session/const.py +34 -0
  147. audex/service/session/exceptions.py +67 -0
  148. audex/service/session/types.py +91 -0
  149. audex/types.py +39 -0
  150. audex/utils.py +287 -0
  151. audex/valueobj/__init__.py +81 -0
  152. audex/valueobj/common/__init__.py +1 -0
  153. audex/valueobj/common/auth.py +84 -0
  154. audex/valueobj/common/email.py +16 -0
  155. audex/valueobj/common/ops.py +22 -0
  156. audex/valueobj/common/phone.py +84 -0
  157. audex/valueobj/common/version.py +72 -0
  158. audex/valueobj/session.py +19 -0
  159. audex/valueobj/utterance.py +15 -0
  160. audex/view/__init__.py +51 -0
  161. audex/view/container.py +17 -0
  162. audex/view/decorators.py +303 -0
  163. audex/view/pages/__init__.py +1 -0
  164. audex/view/pages/dashboard/__init__.py +286 -0
  165. audex/view/pages/dashboard/wifi.py +407 -0
  166. audex/view/pages/login.py +110 -0
  167. audex/view/pages/recording.py +348 -0
  168. audex/view/pages/register.py +202 -0
  169. audex/view/pages/sessions/__init__.py +196 -0
  170. audex/view/pages/sessions/details.py +224 -0
  171. audex/view/pages/sessions/export.py +443 -0
  172. audex/view/pages/settings.py +374 -0
  173. audex/view/pages/voiceprint/__init__.py +1 -0
  174. audex/view/pages/voiceprint/enroll.py +195 -0
  175. audex/view/pages/voiceprint/update.py +195 -0
  176. audex/view/static/css/dashboard.css +452 -0
  177. audex/view/static/css/glass.css +22 -0
  178. audex/view/static/css/global.css +541 -0
  179. audex/view/static/css/login.css +386 -0
  180. audex/view/static/css/recording.css +439 -0
  181. audex/view/static/css/register.css +293 -0
  182. audex/view/static/css/sessions/styles.css +501 -0
  183. audex/view/static/css/settings.css +186 -0
  184. audex/view/static/css/voiceprint/enroll.css +43 -0
  185. audex/view/static/css/voiceprint/styles.css +209 -0
  186. audex/view/static/css/voiceprint/update.css +44 -0
  187. audex/view/static/images/logo.svg +95 -0
  188. audex/view/static/js/recording.js +42 -0
  189. audex-1.0.7a3.dist-info/METADATA +361 -0
  190. audex-1.0.7a3.dist-info/RECORD +192 -0
  191. audex-1.0.7a3.dist-info/WHEEL +4 -0
  192. audex-1.0.7a3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,652 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+ from audex.entity.doctor import Doctor
6
+ from audex.entity.vp import VP
7
+ from audex.exceptions import NoActiveSessionError
8
+ from audex.filters.generated import doctor_filter
9
+ from audex.filters.generated import vp_filter
10
+ from audex.helper.mixin import LoggingMixin
11
+ from audex.lib.cache import KVCache
12
+ from audex.lib.recorder import AudioFormat
13
+ from audex.lib.recorder import AudioRecorder
14
+ from audex.lib.repos.doctor import DoctorRepository
15
+ from audex.lib.repos.vp import VPRepository
16
+ from audex.lib.session import SessionManager
17
+ from audex.lib.vpr import VPR
18
+ from audex.lib.vpr import VPRError
19
+ from audex.service import BaseService
20
+ from audex.service.decorators import require_auth
21
+ from audex.service.doctor.const import ErrorMessages
22
+ from audex.service.doctor.const import InvalidCredentialReasons
23
+ from audex.service.doctor.exceptions import DoctorNotFoundError
24
+ from audex.service.doctor.exceptions import DoctorServiceError
25
+ from audex.service.doctor.exceptions import DuplicateEIDError
26
+ from audex.service.doctor.exceptions import InternalDoctorServiceError
27
+ from audex.service.doctor.exceptions import InvalidCredentialsError
28
+ from audex.service.doctor.exceptions import VoiceprintNotFoundError
29
+ from audex.service.doctor.types import LoginCommand
30
+ from audex.service.doctor.types import RegisterCommand
31
+ from audex.service.doctor.types import UpdateCommand
32
+ from audex.service.doctor.types import VPEnrollResult
33
+ from audex.types import AbstractSession
34
+ from audex.valueobj.common.auth import Password
35
+
36
+
37
+ class DoctorServiceConfig(t.NamedTuple):
38
+ """DoctorService configuration.
39
+
40
+ Attributes:
41
+ vpr_sr: Sample rate for VPR verification.
42
+ vpr_text_content: Text content for VPR enrollment.
43
+ """
44
+
45
+ vpr_sr: int = 16000
46
+ vpr_text_content: str = "请朗读: 您好,我是一名医生,我将为您提供专业的医疗服务。"
47
+
48
+
49
+ class DoctorService(BaseService):
50
+ """Service for managing doctor accounts and voiceprint
51
+ operations."""
52
+
53
+ __logtag__ = "audex.service.doctor"
54
+
55
+ def __init__(
56
+ self,
57
+ session_manager: SessionManager,
58
+ cache: KVCache,
59
+ config: DoctorServiceConfig,
60
+ doctor_repo: DoctorRepository,
61
+ vp_repo: VPRepository,
62
+ vpr: VPR,
63
+ recorder: AudioRecorder,
64
+ ):
65
+ super().__init__(session_manager=session_manager, cache=cache, doctor_repo=doctor_repo)
66
+ self.config = config
67
+ self.vp_repo = vp_repo
68
+ self.vpr = vpr
69
+ self.recorder = recorder
70
+
71
+ async def login(self, command: LoginCommand) -> None:
72
+ """Login a doctor with credentials.
73
+
74
+ Args:
75
+ command: Login command with eid and password.
76
+
77
+ Raises:
78
+ InvalidCredentialsError: If credentials are invalid.
79
+ """
80
+ try:
81
+ f = doctor_filter().eid.eq(command.eid)
82
+ doctor = await self.doctor_repo.first(f.build())
83
+
84
+ if not doctor:
85
+ raise InvalidCredentialsError(
86
+ ErrorMessages.ACCOUNT_NOT_FOUND,
87
+ reason=InvalidCredentialReasons.DOCTOR_NOT_FOUND,
88
+ )
89
+
90
+ if not doctor.is_active:
91
+ raise InvalidCredentialsError(
92
+ ErrorMessages.ACCOUNT_INACTIVE,
93
+ reason=InvalidCredentialReasons.ACCOUNT_INACTIVE,
94
+ )
95
+
96
+ if not doctor.verify_password(command.password):
97
+ raise InvalidCredentialsError(
98
+ ErrorMessages.INVALID_PASSWORD,
99
+ reason=InvalidCredentialReasons.INVALID_PASSWORD,
100
+ )
101
+
102
+ await self.session_manager.login(
103
+ doctor_id=doctor.id,
104
+ eid=doctor.eid,
105
+ doctor_name=doctor.name,
106
+ )
107
+
108
+ self.logger.info(f"Doctor {doctor.eid} logged in successfully")
109
+
110
+ except InvalidCredentialsError:
111
+ raise
112
+ except Exception as e:
113
+ self.logger.error(f"Login failed: {e}")
114
+ raise InternalDoctorServiceError() from e
115
+
116
+ async def is_logged_in(self) -> bool:
117
+ """Check if there is an active session."""
118
+ try:
119
+ session = await self.session_manager.get_session()
120
+ return session is not None
121
+ except Exception as e:
122
+ self.logger.error(f"Failed to check login status: {e}")
123
+ return False
124
+
125
+ @require_auth
126
+ async def logout(self) -> None:
127
+ """Logout the current doctor."""
128
+ try:
129
+ if not await self.session_manager.logout():
130
+ self.logger.warning("Logout called but no active session")
131
+ else:
132
+ self.logger.info("Doctor logged out successfully")
133
+ except Exception as e:
134
+ self.logger.error(f"Logout failed: {e}")
135
+ raise InternalDoctorServiceError() from e
136
+
137
+ async def register(self, command: RegisterCommand) -> Doctor:
138
+ """Register a new doctor account.
139
+
140
+ Args:
141
+ command: Registration command with doctor information.
142
+
143
+ Returns:
144
+ The created Doctor entity.
145
+
146
+ Raises:
147
+ DuplicateEIDError: If EID already exists.
148
+ InternalDoctorServiceError: For internal errors.
149
+ """
150
+ try:
151
+ # Check if EID already exists
152
+ f = doctor_filter().eid.eq(command.eid)
153
+ existing = await self.doctor_repo.first(f.build())
154
+ if existing:
155
+ raise DuplicateEIDError(ErrorMessages.DUPLICATE_EID, eid=command.eid)
156
+
157
+ doctor = Doctor(
158
+ eid=command.eid,
159
+ password_hash=command.password.hash(),
160
+ name=command.name,
161
+ department=command.department,
162
+ title=command.title,
163
+ hospital=command.hospital,
164
+ phone=command.phone,
165
+ email=command.email,
166
+ is_active=True,
167
+ )
168
+
169
+ uid = await self.doctor_repo.create(doctor)
170
+ doctor.id = uid
171
+
172
+ # Auto-login after registration
173
+ await self.session_manager.login(
174
+ doctor_id=uid,
175
+ eid=doctor.eid,
176
+ doctor_name=doctor.name,
177
+ )
178
+
179
+ self.logger.info(f"Registered new doctor {doctor.eid}")
180
+ return doctor
181
+
182
+ except DuplicateEIDError:
183
+ raise
184
+ except Exception as e:
185
+ self.logger.error(f"Registration failed: {e}")
186
+ raise InternalDoctorServiceError(ErrorMessages.REGISTRATION_FAILED) from e
187
+
188
+ @require_auth
189
+ async def delete_account(self) -> None:
190
+ """Delete the current doctor's account and all associated
191
+ data."""
192
+ try:
193
+ session = await self.session_manager.get_session()
194
+ if not session:
195
+ raise NoActiveSessionError(ErrorMessages.NO_ACTIVE_SESSION)
196
+
197
+ doctor = await self.doctor_repo.read(session.doctor_id)
198
+ if not doctor:
199
+ raise DoctorNotFoundError(
200
+ ErrorMessages.DOCTOR_NOT_FOUND,
201
+ doctor_id=session.doctor_id,
202
+ )
203
+
204
+ # Delete all voiceprint registrations
205
+ f = vp_filter().doctor_id.eq(doctor.id)
206
+ await self.vp_repo.delete_many(f.build())
207
+
208
+ # Logout first
209
+ await self.session_manager.logout()
210
+
211
+ # Delete doctor account
212
+ if not await self.doctor_repo.delete(doctor.id):
213
+ raise DoctorServiceError(ErrorMessages.DOCTOR_DELETE_FAILED)
214
+
215
+ self.logger.info(f"Deleted doctor account {doctor.eid}")
216
+
217
+ except (NoActiveSessionError, DoctorNotFoundError, DoctorServiceError):
218
+ raise
219
+ except Exception as e:
220
+ self.logger.error(f"Failed to delete account: {e}")
221
+ raise InternalDoctorServiceError() from e
222
+
223
+ @require_auth
224
+ async def current_doctor(self) -> Doctor:
225
+ """Get the current logged-in doctor.
226
+
227
+ Returns:
228
+ The current Doctor entity.
229
+
230
+ Raises:
231
+ NoActiveSessionError: If no active session.
232
+ DoctorNotFoundError: If doctor not found.
233
+ """
234
+ try:
235
+ session = await self.session_manager.get_session()
236
+ if not session:
237
+ raise NoActiveSessionError(ErrorMessages.NO_ACTIVE_SESSION)
238
+
239
+ doctor = await self.doctor_repo.read(session.doctor_id)
240
+ if not doctor:
241
+ raise DoctorNotFoundError(
242
+ ErrorMessages.DOCTOR_NOT_FOUND,
243
+ doctor_id=session.doctor_id,
244
+ )
245
+
246
+ return doctor
247
+
248
+ except (NoActiveSessionError, DoctorNotFoundError):
249
+ raise
250
+ except Exception as e:
251
+ self.logger.error(f"Failed to get current doctor: {e}")
252
+ raise InternalDoctorServiceError() from e
253
+
254
+ @require_auth
255
+ async def enroll_vp(self) -> VPEnrollmentContext:
256
+ """Start voiceprint enrollment for current doctor."""
257
+ try:
258
+ session = await self.session_manager.get_session()
259
+ if not session:
260
+ raise NoActiveSessionError(ErrorMessages.NO_ACTIVE_SESSION)
261
+
262
+ doctor = await self.doctor_repo.read(session.doctor_id)
263
+ if not doctor:
264
+ raise DoctorNotFoundError(
265
+ ErrorMessages.DOCTOR_NOT_FOUND,
266
+ doctor_id=session.doctor_id,
267
+ )
268
+
269
+ return VPEnrollmentContext(
270
+ doctor=doctor,
271
+ vp_repo=self.vp_repo,
272
+ recorder=self.recorder,
273
+ vpr=self.vpr,
274
+ text_content=self.config.vpr_text_content,
275
+ sample_rate=self.config.vpr_sr,
276
+ group_id=self.vpr.group_id,
277
+ )
278
+
279
+ except (NoActiveSessionError, DoctorNotFoundError):
280
+ raise
281
+ except Exception as e:
282
+ self.logger.error(f"Failed to start VP enrollment: {e}")
283
+ raise InternalDoctorServiceError() from e
284
+
285
+ @require_auth
286
+ async def update_vp(self) -> VPUpdateContext:
287
+ """Start voiceprint update for current doctor."""
288
+ try:
289
+ session = await self.session_manager.get_session()
290
+ if not session:
291
+ raise NoActiveSessionError(ErrorMessages.NO_ACTIVE_SESSION)
292
+
293
+ doctor = await self.doctor_repo.read(session.doctor_id)
294
+ if not doctor:
295
+ raise DoctorNotFoundError(
296
+ ErrorMessages.DOCTOR_NOT_FOUND,
297
+ doctor_id=session.doctor_id,
298
+ )
299
+
300
+ # Get active voiceprint
301
+ f = vp_filter().doctor_id.eq(doctor.id).is_active.eq(True)
302
+ vp = await self.vp_repo.first(f.build())
303
+
304
+ if not vp:
305
+ raise VoiceprintNotFoundError(
306
+ ErrorMessages.VOICEPRINT_NOT_FOUND,
307
+ doctor_id=doctor.id,
308
+ )
309
+
310
+ return VPUpdateContext(
311
+ doctor=doctor,
312
+ vp=vp,
313
+ vp_repo=self.vp_repo,
314
+ recorder=self.recorder,
315
+ vpr=self.vpr,
316
+ text_content=self.config.vpr_text_content,
317
+ sample_rate=self.config.vpr_sr,
318
+ )
319
+
320
+ except (NoActiveSessionError, DoctorNotFoundError, VoiceprintNotFoundError):
321
+ raise
322
+ except Exception as e:
323
+ self.logger.error(f"Failed to start VP update: {e}")
324
+ raise InternalDoctorServiceError() from e
325
+
326
+ @require_auth
327
+ async def get_active_vp(self) -> VP | None:
328
+ """Get the active voiceprint for current doctor."""
329
+ try:
330
+ session = await self.session_manager.get_session()
331
+ if not session:
332
+ raise NoActiveSessionError(ErrorMessages.NO_ACTIVE_SESSION)
333
+
334
+ doctor = await self.doctor_repo.read(session.doctor_id)
335
+ if not doctor:
336
+ raise DoctorNotFoundError(
337
+ ErrorMessages.DOCTOR_NOT_FOUND,
338
+ doctor_id=session.doctor_id,
339
+ )
340
+
341
+ f = vp_filter().doctor_id.eq(doctor.id).is_active.eq(True)
342
+ return await self.vp_repo.first(f.build())
343
+
344
+ except (NoActiveSessionError, DoctorNotFoundError):
345
+ raise
346
+ except Exception as e:
347
+ self.logger.error(f"Failed to get active VP: {e}")
348
+ raise InternalDoctorServiceError() from e
349
+
350
+ @require_auth
351
+ async def has_voiceprint(self) -> bool:
352
+ """Check if current doctor has an active voiceprint."""
353
+ try:
354
+ vp = await self.get_active_vp()
355
+ return vp is not None
356
+ except NoActiveSessionError:
357
+ raise
358
+ except DoctorNotFoundError:
359
+ raise
360
+ except Exception:
361
+ return False
362
+
363
+ @require_auth
364
+ async def deactivate_vp(self) -> None:
365
+ """Deactivate the current doctor's voiceprint."""
366
+ try:
367
+ session = await self.session_manager.get_session()
368
+ if not session:
369
+ raise NoActiveSessionError(ErrorMessages.NO_ACTIVE_SESSION)
370
+
371
+ doctor = await self.doctor_repo.read(session.doctor_id)
372
+ if not doctor:
373
+ raise DoctorNotFoundError(
374
+ ErrorMessages.DOCTOR_NOT_FOUND,
375
+ doctor_id=session.doctor_id,
376
+ )
377
+
378
+ f = vp_filter().doctor_id.eq(doctor.id).is_active.eq(True)
379
+ vp = await self.vp_repo.first(f.build())
380
+
381
+ if not vp:
382
+ raise VoiceprintNotFoundError(
383
+ ErrorMessages.VOICEPRINT_NOT_FOUND,
384
+ doctor_id=doctor.id,
385
+ )
386
+
387
+ vp.is_active = False
388
+ await self.vp_repo.update(vp)
389
+
390
+ self.logger.info(f"Deactivated voiceprint for doctor {doctor.eid}")
391
+
392
+ except (NoActiveSessionError, DoctorNotFoundError, VoiceprintNotFoundError):
393
+ raise
394
+ except Exception as e:
395
+ self.logger.error(f"Failed to deactivate VP: {e}")
396
+ raise InternalDoctorServiceError() from e
397
+
398
+ @require_auth
399
+ async def update(self, command: UpdateCommand) -> Doctor:
400
+ """Update current doctor's profile information."""
401
+ try:
402
+ session = await self.session_manager.get_session()
403
+ if not session:
404
+ raise NoActiveSessionError(ErrorMessages.NO_ACTIVE_SESSION)
405
+
406
+ doctor = await self.doctor_repo.read(session.doctor_id)
407
+ if not doctor:
408
+ raise DoctorNotFoundError(
409
+ ErrorMessages.DOCTOR_NOT_FOUND,
410
+ doctor_id=session.doctor_id,
411
+ )
412
+
413
+ # Update fields if provided
414
+ if command.name is not None:
415
+ doctor.name = command.name
416
+ if command.department is not None:
417
+ doctor.department = command.department
418
+ if command.title is not None:
419
+ doctor.title = command.title
420
+ if command.hospital is not None:
421
+ doctor.hospital = command.hospital
422
+ if command.phone is not None:
423
+ doctor.phone = command.phone
424
+ if command.email is not None:
425
+ doctor.email = command.email
426
+
427
+ doctor.touch()
428
+ await self.doctor_repo.update(doctor)
429
+
430
+ self.logger.info(f"Updated profile for doctor {doctor.eid}")
431
+ return doctor
432
+
433
+ except (NoActiveSessionError, DoctorNotFoundError):
434
+ raise
435
+ except Exception as e:
436
+ self.logger.error(f"Failed to update profile: {e}")
437
+ raise InternalDoctorServiceError() from e
438
+
439
+ @require_auth
440
+ async def change_password(
441
+ self,
442
+ old_password: Password,
443
+ new_password: Password,
444
+ ) -> None:
445
+ """Change the current doctor's password."""
446
+ try:
447
+ session = await self.session_manager.get_session()
448
+ if not session:
449
+ raise NoActiveSessionError(ErrorMessages.NO_ACTIVE_SESSION)
450
+
451
+ doctor = await self.doctor_repo.read(session.doctor_id)
452
+ if not doctor:
453
+ raise DoctorNotFoundError(
454
+ ErrorMessages.DOCTOR_NOT_FOUND,
455
+ doctor_id=session.doctor_id,
456
+ )
457
+
458
+ if not doctor.verify_password(old_password):
459
+ raise InvalidCredentialsError(
460
+ ErrorMessages.OLD_PASSWORD_INCORRECT,
461
+ reason=InvalidCredentialReasons.INVALID_PASSWORD,
462
+ )
463
+
464
+ doctor.password_hash = new_password.hash()
465
+ doctor.touch()
466
+ await self.doctor_repo.update(doctor)
467
+
468
+ self.logger.info(f"Changed password for doctor {doctor.eid}")
469
+
470
+ except (NoActiveSessionError, DoctorNotFoundError, InvalidCredentialsError):
471
+ raise
472
+ except Exception as e:
473
+ self.logger.error(f"Failed to change password: {e}")
474
+ raise InternalDoctorServiceError() from e
475
+
476
+
477
+ class VPEnrollmentContext(LoggingMixin, AbstractSession):
478
+ """Context for managing voiceprint enrollment with live
479
+ recording."""
480
+
481
+ __logtag__ = "audex.service.doctor:VPEnrollmentContext"
482
+
483
+ def __init__(
484
+ self,
485
+ doctor: Doctor,
486
+ vp_repo: VPRepository,
487
+ recorder: AudioRecorder,
488
+ vpr: VPR,
489
+ text_content: str,
490
+ sample_rate: int,
491
+ group_id: str | None,
492
+ ):
493
+ super().__init__()
494
+ self.doctor = doctor
495
+ self.vp_repo = vp_repo
496
+ self.recorder = recorder
497
+ self.vpr = vpr
498
+ self.text_content = text_content
499
+ self.sample_rate = sample_rate
500
+ self.group_id = group_id
501
+
502
+ async def start(self) -> None:
503
+ """Start recording for voiceprint enrollment."""
504
+ try:
505
+ await self.recorder.start("voiceprints", self.doctor.id, "enrollment")
506
+ self.logger.info(f"Started VP enrollment for doctor {self.doctor.eid}")
507
+ except Exception as e:
508
+ self.logger.error(f"Failed to start recording: {e}")
509
+ raise InternalDoctorServiceError(ErrorMessages.VOICEPRINT_ENROLL_FAILED) from e
510
+
511
+ async def close(self) -> VPEnrollResult:
512
+ """Finish recording and complete enrollment."""
513
+ try:
514
+ # Stop recording and get audio segment
515
+ segment = await self.recorder.stop()
516
+
517
+ # Get audio data for VPR (resample if needed)
518
+ audio_data = await self.recorder.segment(
519
+ started_at=segment.started_at,
520
+ ended_at=segment.ended_at,
521
+ channels=1,
522
+ rate=self.sample_rate,
523
+ format=AudioFormat.MP3,
524
+ )
525
+
526
+ # Enroll with VPR
527
+ try:
528
+ vpr_uid = await self.vpr.enroll(data=audio_data, sr=self.sample_rate, uid=None)
529
+ self.logger.info(f"Enrolled with VPR, uid: {vpr_uid}")
530
+ except VPRError as e:
531
+ self.logger.error(f"VPR enrollment failed: {e}")
532
+ raise InternalDoctorServiceError(ErrorMessages.VOICEPRINT_ENROLL_FAILED) from e
533
+
534
+ # Deactivate existing active VPs
535
+ f = vp_filter().doctor_id.eq(self.doctor.id).is_active.eq(True)
536
+ existing = await self.vp_repo.list(f.build())
537
+
538
+ if existing:
539
+ for vp in existing:
540
+ vp.is_active = False
541
+ await self.vp_repo.update_many(existing)
542
+ self.logger.debug(f"Deactivated {len(existing)} existing VP(s)")
543
+
544
+ # Create new VP
545
+ vp = VP(
546
+ doctor_id=self.doctor.id,
547
+ vpr_uid=vpr_uid,
548
+ vpr_group_id=self.group_id or "",
549
+ audio_key=segment.key,
550
+ text_content=self.text_content,
551
+ sample_rate=self.sample_rate,
552
+ is_active=True,
553
+ )
554
+
555
+ vp_id = await self.vp_repo.create(vp)
556
+ self.logger.info(f"Created VP {vp_id} for doctor {self.doctor.eid}")
557
+
558
+ self.recorder.clear_frames()
559
+
560
+ return VPEnrollResult(
561
+ vp_id=vp_id,
562
+ vpr_uid=vpr_uid,
563
+ audio_key=segment.key,
564
+ duration_ms=segment.duration_ms,
565
+ )
566
+
567
+ except InternalDoctorServiceError:
568
+ raise
569
+ except Exception as e:
570
+ self.logger.error(f"VP enrollment failed: {e}")
571
+ raise InternalDoctorServiceError(ErrorMessages.VOICEPRINT_ENROLL_FAILED) from e
572
+
573
+
574
+ class VPUpdateContext(LoggingMixin, AbstractSession):
575
+ """Context for managing voiceprint update with live recording."""
576
+
577
+ __logtag__ = "audex.service.doctor:VPUpdateContext"
578
+
579
+ def __init__(
580
+ self,
581
+ doctor: Doctor,
582
+ vp: VP,
583
+ vp_repo: VPRepository,
584
+ recorder: AudioRecorder,
585
+ vpr: VPR,
586
+ text_content: str,
587
+ sample_rate: int,
588
+ ):
589
+ super().__init__()
590
+ self.doctor = doctor
591
+ self.vp = vp
592
+ self.vp_repo = vp_repo
593
+ self.recorder = recorder
594
+ self.vpr = vpr
595
+ self.text_content = text_content
596
+ self.sample_rate = sample_rate
597
+
598
+ async def start(self) -> None:
599
+ """Start recording for voiceprint update."""
600
+ try:
601
+ await self.recorder.start("voiceprints", self.doctor.id, "update")
602
+ self.logger.info(f"Started VP update for doctor {self.doctor.eid}")
603
+ except Exception as e:
604
+ self.logger.error(f"Failed to start recording: {e}")
605
+ raise InternalDoctorServiceError(ErrorMessages.VOICEPRINT_UPDATE_FAILED) from e
606
+
607
+ async def close(self) -> VPEnrollResult:
608
+ """Finish recording and complete update."""
609
+ try:
610
+ # Stop recording and get audio segment
611
+ segment = await self.recorder.stop()
612
+
613
+ # Get audio data for VPR (resample if needed)
614
+ audio_data = await self.recorder.segment(
615
+ started_at=segment.started_at,
616
+ ended_at=segment.ended_at,
617
+ channels=1,
618
+ rate=self.sample_rate,
619
+ format=AudioFormat.MP3,
620
+ )
621
+
622
+ # Update VPR
623
+ try:
624
+ await self.vpr.update(uid=self.vp.vpr_uid, data=audio_data, sr=self.sample_rate)
625
+ self.logger.info(f"Updated VPR uid: {self.vp.vpr_uid}")
626
+ except VPRError as e:
627
+ self.logger.error(f"VPR update failed: {e}")
628
+ raise InternalDoctorServiceError(ErrorMessages.VOICEPRINT_UPDATE_FAILED) from e
629
+
630
+ # Update VP record
631
+ self.vp.audio_key = segment.key
632
+ self.vp.text_content = self.text_content
633
+ self.vp.sample_rate = self.sample_rate
634
+ self.vp.touch()
635
+
636
+ await self.vp_repo.update(self.vp)
637
+ self.logger.info(f"Updated VP {self.vp.id} for doctor {self.doctor.eid}")
638
+
639
+ self.recorder.clear_frames()
640
+
641
+ return VPEnrollResult(
642
+ vp_id=self.vp.id,
643
+ vpr_uid=self.vp.vpr_uid,
644
+ audio_key=segment.key,
645
+ duration_ms=segment.duration_ms,
646
+ )
647
+
648
+ except InternalDoctorServiceError:
649
+ raise
650
+ except Exception as e:
651
+ self.logger.error(f"VP update failed: {e}")
652
+ raise InternalDoctorServiceError(ErrorMessages.VOICEPRINT_UPDATE_FAILED) from e
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class InvalidCredentialReasons:
5
+ """Constants for invalid credential reasons."""
6
+
7
+ DEFAULT = "default"
8
+ DOCTOR_NOT_FOUND = "doctor_not_found"
9
+ INVALID_PASSWORD = "invalid_password"
10
+ ACCOUNT_INACTIVE = "account_inactive"
11
+
12
+
13
+ class ErrorMessages:
14
+ """Error messages in Chinese for doctor service."""
15
+
16
+ # Authentication errors
17
+ ACCOUNT_NOT_FOUND = "账号不存在,请检查工号后重试"
18
+ INVALID_PASSWORD = "密码错误,请重新输入"
19
+ ACCOUNT_INACTIVE = "账号已被停用,请联系管理员"
20
+ OLD_PASSWORD_INCORRECT = "原密码错误"
21
+
22
+ # Session errors
23
+ NO_ACTIVE_SESSION = "登录状态已过期,请重新登录"
24
+
25
+ # Doctor errors
26
+ DOCTOR_NOT_FOUND = "未找到医生账号"
27
+ DOCTOR_DELETE_FAILED = "删除医生账号失败,请稍后重试"
28
+
29
+ # Voiceprint errors
30
+ VOICEPRINT_NOT_FOUND = "未找到有效的声纹注册"
31
+ VOICEPRINT_ENROLL_FAILED = "声纹注册失败"
32
+ VOICEPRINT_UPDATE_FAILED = "声纹更新失败"
33
+
34
+ # Registration errors
35
+ DUPLICATE_EID = "该工号已被注册"
36
+ REGISTRATION_FAILED = "注册失败,请稍后重试"