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.
- audex/__init__.py +9 -0
- audex/__main__.py +7 -0
- audex/cli/__init__.py +189 -0
- audex/cli/apis/__init__.py +12 -0
- audex/cli/apis/init/__init__.py +34 -0
- audex/cli/apis/init/gencfg.py +130 -0
- audex/cli/apis/init/setup.py +330 -0
- audex/cli/apis/init/vprgroup.py +125 -0
- audex/cli/apis/serve.py +141 -0
- audex/cli/args.py +356 -0
- audex/cli/exceptions.py +44 -0
- audex/cli/helper/__init__.py +0 -0
- audex/cli/helper/ansi.py +193 -0
- audex/cli/helper/display.py +288 -0
- audex/config/__init__.py +64 -0
- audex/config/core/__init__.py +30 -0
- audex/config/core/app.py +29 -0
- audex/config/core/audio.py +45 -0
- audex/config/core/logging.py +163 -0
- audex/config/core/session.py +11 -0
- audex/config/helper/__init__.py +1 -0
- audex/config/helper/client/__init__.py +1 -0
- audex/config/helper/client/http.py +28 -0
- audex/config/helper/client/websocket.py +21 -0
- audex/config/helper/provider/__init__.py +1 -0
- audex/config/helper/provider/dashscope.py +13 -0
- audex/config/helper/provider/unisound.py +18 -0
- audex/config/helper/provider/xfyun.py +23 -0
- audex/config/infrastructure/__init__.py +31 -0
- audex/config/infrastructure/cache.py +51 -0
- audex/config/infrastructure/database.py +48 -0
- audex/config/infrastructure/recorder.py +32 -0
- audex/config/infrastructure/store.py +19 -0
- audex/config/provider/__init__.py +18 -0
- audex/config/provider/transcription.py +109 -0
- audex/config/provider/vpr.py +99 -0
- audex/container.py +40 -0
- audex/entity/__init__.py +468 -0
- audex/entity/doctor.py +109 -0
- audex/entity/doctor.pyi +51 -0
- audex/entity/fields.py +401 -0
- audex/entity/segment.py +115 -0
- audex/entity/segment.pyi +38 -0
- audex/entity/session.py +133 -0
- audex/entity/session.pyi +47 -0
- audex/entity/utterance.py +142 -0
- audex/entity/utterance.pyi +48 -0
- audex/entity/vp.py +68 -0
- audex/entity/vp.pyi +35 -0
- audex/exceptions.py +157 -0
- audex/filters/__init__.py +692 -0
- audex/filters/generated/__init__.py +21 -0
- audex/filters/generated/doctor.py +987 -0
- audex/filters/generated/segment.py +723 -0
- audex/filters/generated/session.py +978 -0
- audex/filters/generated/utterance.py +939 -0
- audex/filters/generated/vp.py +815 -0
- audex/helper/__init__.py +1 -0
- audex/helper/hash.py +33 -0
- audex/helper/mixin.py +65 -0
- audex/helper/net.py +19 -0
- audex/helper/settings/__init__.py +830 -0
- audex/helper/settings/fields.py +317 -0
- audex/helper/stream.py +153 -0
- audex/injectors/__init__.py +1 -0
- audex/injectors/config.py +12 -0
- audex/injectors/lifespan.py +7 -0
- audex/lib/__init__.py +1 -0
- audex/lib/cache/__init__.py +383 -0
- audex/lib/cache/inmemory.py +513 -0
- audex/lib/database/__init__.py +83 -0
- audex/lib/database/sqlite.py +406 -0
- audex/lib/exporter.py +189 -0
- audex/lib/injectors/__init__.py +1 -0
- audex/lib/injectors/cache.py +25 -0
- audex/lib/injectors/container.py +47 -0
- audex/lib/injectors/exporter.py +26 -0
- audex/lib/injectors/recorder.py +33 -0
- audex/lib/injectors/server.py +17 -0
- audex/lib/injectors/session.py +18 -0
- audex/lib/injectors/sqlite.py +24 -0
- audex/lib/injectors/store.py +13 -0
- audex/lib/injectors/transcription.py +42 -0
- audex/lib/injectors/usb.py +12 -0
- audex/lib/injectors/vpr.py +65 -0
- audex/lib/injectors/wifi.py +7 -0
- audex/lib/recorder.py +844 -0
- audex/lib/repos/__init__.py +149 -0
- audex/lib/repos/container.py +23 -0
- audex/lib/repos/database/__init__.py +1 -0
- audex/lib/repos/database/sqlite.py +672 -0
- audex/lib/repos/decorators.py +74 -0
- audex/lib/repos/doctor.py +286 -0
- audex/lib/repos/segment.py +302 -0
- audex/lib/repos/session.py +285 -0
- audex/lib/repos/tables/__init__.py +70 -0
- audex/lib/repos/tables/doctor.py +137 -0
- audex/lib/repos/tables/segment.py +113 -0
- audex/lib/repos/tables/session.py +140 -0
- audex/lib/repos/tables/utterance.py +131 -0
- audex/lib/repos/tables/vp.py +102 -0
- audex/lib/repos/utterance.py +288 -0
- audex/lib/repos/vp.py +286 -0
- audex/lib/restful.py +251 -0
- audex/lib/server/__init__.py +97 -0
- audex/lib/server/auth.py +98 -0
- audex/lib/server/handlers.py +248 -0
- audex/lib/server/templates/index.html.j2 +226 -0
- audex/lib/server/templates/login.html.j2 +111 -0
- audex/lib/server/templates/static/script.js +68 -0
- audex/lib/server/templates/static/style.css +579 -0
- audex/lib/server/types.py +123 -0
- audex/lib/session.py +503 -0
- audex/lib/store/__init__.py +238 -0
- audex/lib/store/localfile.py +411 -0
- audex/lib/transcription/__init__.py +33 -0
- audex/lib/transcription/dashscope.py +525 -0
- audex/lib/transcription/events.py +62 -0
- audex/lib/usb.py +554 -0
- audex/lib/vpr/__init__.py +38 -0
- audex/lib/vpr/unisound/__init__.py +185 -0
- audex/lib/vpr/unisound/types.py +469 -0
- audex/lib/vpr/xfyun/__init__.py +483 -0
- audex/lib/vpr/xfyun/types.py +679 -0
- audex/lib/websocket/__init__.py +8 -0
- audex/lib/websocket/connection.py +485 -0
- audex/lib/websocket/pool.py +991 -0
- audex/lib/wifi.py +1146 -0
- audex/lifespan.py +75 -0
- audex/service/__init__.py +27 -0
- audex/service/decorators.py +73 -0
- audex/service/doctor/__init__.py +652 -0
- audex/service/doctor/const.py +36 -0
- audex/service/doctor/exceptions.py +96 -0
- audex/service/doctor/types.py +54 -0
- audex/service/export/__init__.py +236 -0
- audex/service/export/const.py +17 -0
- audex/service/export/exceptions.py +34 -0
- audex/service/export/types.py +21 -0
- audex/service/injectors/__init__.py +1 -0
- audex/service/injectors/container.py +53 -0
- audex/service/injectors/doctor.py +34 -0
- audex/service/injectors/export.py +27 -0
- audex/service/injectors/session.py +49 -0
- audex/service/session/__init__.py +754 -0
- audex/service/session/const.py +34 -0
- audex/service/session/exceptions.py +67 -0
- audex/service/session/types.py +91 -0
- audex/types.py +39 -0
- audex/utils.py +287 -0
- audex/valueobj/__init__.py +81 -0
- audex/valueobj/common/__init__.py +1 -0
- audex/valueobj/common/auth.py +84 -0
- audex/valueobj/common/email.py +16 -0
- audex/valueobj/common/ops.py +22 -0
- audex/valueobj/common/phone.py +84 -0
- audex/valueobj/common/version.py +72 -0
- audex/valueobj/session.py +19 -0
- audex/valueobj/utterance.py +15 -0
- audex/view/__init__.py +51 -0
- audex/view/container.py +17 -0
- audex/view/decorators.py +303 -0
- audex/view/pages/__init__.py +1 -0
- audex/view/pages/dashboard/__init__.py +286 -0
- audex/view/pages/dashboard/wifi.py +407 -0
- audex/view/pages/login.py +110 -0
- audex/view/pages/recording.py +348 -0
- audex/view/pages/register.py +202 -0
- audex/view/pages/sessions/__init__.py +196 -0
- audex/view/pages/sessions/details.py +224 -0
- audex/view/pages/sessions/export.py +443 -0
- audex/view/pages/settings.py +374 -0
- audex/view/pages/voiceprint/__init__.py +1 -0
- audex/view/pages/voiceprint/enroll.py +195 -0
- audex/view/pages/voiceprint/update.py +195 -0
- audex/view/static/css/dashboard.css +452 -0
- audex/view/static/css/glass.css +22 -0
- audex/view/static/css/global.css +541 -0
- audex/view/static/css/login.css +386 -0
- audex/view/static/css/recording.css +439 -0
- audex/view/static/css/register.css +293 -0
- audex/view/static/css/sessions/styles.css +501 -0
- audex/view/static/css/settings.css +186 -0
- audex/view/static/css/voiceprint/enroll.css +43 -0
- audex/view/static/css/voiceprint/styles.css +209 -0
- audex/view/static/css/voiceprint/update.css +44 -0
- audex/view/static/images/logo.svg +95 -0
- audex/view/static/js/recording.js +42 -0
- audex-1.0.7a3.dist-info/METADATA +361 -0
- audex-1.0.7a3.dist-info/RECORD +192 -0
- audex-1.0.7a3.dist-info/WHEEL +4 -0
- 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 = "注册失败,请稍后重试"
|