Coverage for baibu/chat/services.py: 100%

365 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-10-06 13:07 +0000

1"""Conversations, sends, runs and the audit trail. 

2 

3A send creates the user's message and a queued Run in one transaction; the 

4Run is executed by a Celery task once committed. Sends are idempotent: the 

5same key returns the same message and run. A conversation has at most one 

6active run at a time. A failed run can be retried, which creates a new run 

7for the same message, up to ``CHAT_MAX_ATTEMPTS`` per message. 

8 

9A voice send stores the recording, creates the user's message empty and 

10queues transcription; the transcript then becomes the message and the reply 

11is queued as for a typed message. While transcription is pending the 

12conversation is busy, as it is while a reply is being written. 

13""" 

14 

15import json 

16import logging 

17import time 

18import uuid 

19from datetime import timedelta 

20 

21from django.conf import settings 

22from django.db import transaction 

23from django.template import Context 

24from django.template import Template 

25from django.template.loader import get_template 

26from django.utils import timezone 

27from django.utils.translation import get_language_info 

28 

29from baibu.core import storage 

30from baibu.users.consent import consent_state 

31 

32from . import llm 

33from . import speech 

34from . import tools 

35from .models import AudioClip 

36from .models import Conversation 

37from .models import ConversationEvent 

38from .models import Message 

39from .models import ModelVariant 

40from .models import Prompt 

41from .models import Run 

42from .models import ToolInvocation 

43 

44logger = logging.getLogger(__name__) 

45 

46 

47class ChatError(Exception): 

48 """A request the user can correct; the message is safe to show.""" 

49 

50 

51class ConversationBusyError(ChatError): 

52 pass 

53 

54 

55class RetryNotAllowedError(ChatError): 

56 pass 

57 

58 

59BUSY_MESSAGE = "Please wait for the reply to your last message." 

60AUDIO_AREA = "chat/audio" 

61 

62 

63def record_event(*, conversation, type: str, run=None, message=None, **payload) -> ConversationEvent: # noqa: A002 

64 return ConversationEvent.objects.create( 

65 conversation=conversation, run=run, message=message, type=type, payload=payload 

66 ) 

67 

68 

69def start_conversation(*, user, language_code: str = "") -> Conversation: 

70 tier = consent_state(user=user, scope=settings.CHAT_CONSENT_SCOPE).tier 

71 conversation = Conversation.objects.create(user=user, language_code=language_code, consent_tier=tier) 

72 record_event(conversation=conversation, type="conversation_started", consent_tier=tier) 

73 return conversation 

74 

75 

76def _enqueue(run: Run) -> None: 

77 from .tasks import execute_run 

78 

79 transaction.on_commit(lambda: execute_run.delay(str(run.pk))) 

80 

81 

82def is_busy(conversation: Conversation) -> bool: 

83 """A reply is being written, or a voice message is being transcribed.""" 

84 return ( 

85 conversation.runs.filter(status__in=Run.ACTIVE_STATUSES).exists() 

86 or conversation.audio_clips.filter(status__in=AudioClip.ACTIVE_STATUSES).exists() 

87 ) 

88 

89 

90def _queue_reply(conversation: Conversation, message: Message, *, via: str = "text") -> Run: 

91 """Queue the first run for a user message whose content is final. Call 

92 inside a transaction holding the conversation's row lock.""" 

93 run = Run.objects.create( 

94 conversation=conversation, triggering_message=message, idempotency_key=f"{message.idempotency_key}:1" 

95 ) 

96 record_event( 

97 conversation=conversation, type="message_sent", message=message, characters=len(message.content), via=via 

98 ) 

99 record_event(conversation=conversation, type="run_queued", run=run, message=message, attempt=1) 

100 if not conversation.title: 

101 conversation.title = message.content.splitlines()[0][:80] 

102 conversation.last_activity_at = timezone.now() 

103 conversation.save(update_fields=["title", "last_activity_at"]) 

104 _enqueue(run) 

105 return run 

106 

107 

108def send_message(*, conversation: Conversation, content: str, idempotency_key: str) -> tuple[Message, Run]: 

109 """Add the user's message and queue a reply. Safe to call twice with one key.""" 

110 content = content.strip() 

111 with transaction.atomic(): 

112 conversation = Conversation.objects.select_for_update().get(pk=conversation.pk) 

113 existing = conversation.messages.filter(idempotency_key=idempotency_key).first() 

114 if existing is not None: 

115 return existing, existing.runs.order_by("-created_at").first() 

116 if is_busy(conversation): 

117 raise ConversationBusyError(BUSY_MESSAGE) 

118 message = Message.objects.create( 

119 conversation=conversation, role=Message.Role.USER, content=content, idempotency_key=idempotency_key 

120 ) 

121 run = _queue_reply(conversation, message) 

122 return message, run 

123 

124 

125def retry_run(run: Run) -> Run: 

126 """Queue a new attempt at a failed run's reply.""" 

127 with transaction.atomic(): 

128 conversation = Conversation.objects.select_for_update().get(pk=run.conversation_id) 

129 run = Run.objects.get(pk=run.pk) 

130 if run.status != Run.Status.FAILED: 

131 msg = "Only a failed reply can be retried." 

132 raise RetryNotAllowedError(msg) 

133 latest = run.triggering_message.runs.order_by("-attempt").first() 

134 if latest.pk != run.pk: 

135 return latest 

136 if run.attempt >= settings.CHAT_MAX_ATTEMPTS: 

137 msg = "This message has been tried too many times." 

138 raise RetryNotAllowedError(msg) 

139 if is_busy(conversation): 

140 raise ConversationBusyError(BUSY_MESSAGE) 

141 attempt = run.attempt + 1 

142 new_run = Run.objects.create( 

143 conversation=conversation, 

144 triggering_message=run.triggering_message, 

145 attempt=attempt, 

146 idempotency_key=f"{run.idempotency_key.rsplit(':', 1)[0]}:{attempt}", 

147 ) 

148 record_event( 

149 conversation=conversation, type="run_retried", run=new_run, retried_run=str(run.pk), attempt=attempt 

150 ) 

151 conversation.last_activity_at = timezone.now() 

152 conversation.save(update_fields=["last_activity_at"]) 

153 _enqueue(new_run) 

154 return new_run 

155 

156 

157# --- Prompt and model ------------------------------------------------------- 

158 

159 

160def active_prompt(name: str = "system") -> Prompt | None: 

161 return Prompt.objects.filter(name=name, active=True).first() 

162 

163 

164def render_system_prompt(conversation: Conversation, prompt: Prompt | None) -> str: 

165 from baibu.core.context_processors import platform 

166 

167 language_code = conversation.language_code or settings.LANGUAGE_CODE 

168 try: 

169 language_name = get_language_info(language_code)["name"] 

170 except KeyError: 

171 language_name = language_code 

172 context = { 

173 **platform(None), 

174 "user_name": conversation.user.name, 

175 "language_code": language_code, 

176 "language_name": language_name, 

177 } 

178 if prompt is None: 

179 return get_template("chat/system_prompt.txt").render(context).strip() 

180 return Template(prompt.body).render(Context(context, autoescape=False)).strip() 

181 

182 

183def default_variant() -> ModelVariant | None: 

184 return ModelVariant.objects.filter(is_default=True).first() 

185 

186 

187def build_messages(run: Run, system_prompt: str) -> list[dict]: 

188 """System prompt, then recent turns up to and including the triggering message.""" 

189 history = list( 

190 run.conversation.messages.filter(created_at__lte=run.triggering_message.created_at) 

191 # Voice messages without a transcript have no content to send. 

192 .exclude(audio__status__in=[*AudioClip.ACTIVE_STATUSES, AudioClip.Status.FAILED]) 

193 .order_by("-created_at", "-id") 

194 .values("role", "content")[: settings.CHAT_CONTEXT_MESSAGES] 

195 ) 

196 history.reverse() 

197 return [{"role": "system", "content": system_prompt}, *history] 

198 

199 

200# --- Execution -------------------------------------------------------------- 

201 

202 

203def claim(run_id) -> Run | None: 

204 """Move a queued run to running. Returns None if someone else has it.""" 

205 now = timezone.now() 

206 claimed = Run.objects.filter(pk=run_id, status=Run.Status.QUEUED).update(status=Run.Status.RUNNING, started_at=now) 

207 if not claimed: 

208 return None 

209 run = Run.objects.select_related("conversation__user", "triggering_message").get(pk=run_id) 

210 record_event(conversation=run.conversation, type="run_started", run=run) 

211 return run 

212 

213 

214def execute(run_id) -> Run | None: 

215 run = claim(run_id) 

216 if run is None: 

217 return None 

218 prompt = active_prompt() 

219 variant = default_variant() 

220 config = llm.ModelConfig.from_variant(variant) 

221 run.prompt = prompt 

222 run.model_variant = variant 

223 run.prompt_version = str(prompt) if prompt else "built-in" 

224 try: 

225 messages = build_messages(run, render_system_prompt(run.conversation, prompt)) 

226 completion = generate(run, config, messages) 

227 except llm.ModelError as exc: 

228 return fail(run, code=exc.code, message=str(exc)) 

229 except Exception as exc: 

230 logger.exception("Chat run %s failed", run.pk) 

231 return fail(run, code="internal_error", message=type(exc).__name__) 

232 return complete(run, completion) 

233 

234 

235def generate(run: Run, config: llm.ModelConfig, messages: list[dict]) -> llm.Completion: 

236 """Ask the model for the reply, running any tools it calls. 

237 

238 The model may call tools for up to ``CHAT_MAX_TOOL_ROUNDS`` rounds; the 

239 last call offers no tools, so it has to answer. Sources from tools are 

240 collected on ``run.sources`` for the reply. 

241 """ 

242 run.model_name = config.model 

243 run.sources = [] 

244 available = {tool.name: tool for tool in tools.available_tools()} 

245 messages = list(messages) 

246 for round_number in range(settings.CHAT_MAX_TOOL_ROUNDS + 1): 

247 offer = [tool.schema() for tool in available.values()] if round_number < settings.CHAT_MAX_TOOL_ROUNDS else [] 

248 completion = llm.complete(config, messages, tools=offer or None) 

249 if not completion.tool_calls: 

250 return completion 

251 messages.append(completion.raw_message) 

252 for call in completion.tool_calls: 

253 messages.append({"role": "tool", "tool_call_id": call.id, "content": _run_tool(run, available, call)}) 

254 return completion # pragma: no cover - the last round offers no tools 

255 

256 

257def _run_tool(run: Run, available: dict, call: llm.ToolCall) -> str: 

258 started = time.monotonic() 

259 tool = available.get(call.name) 

260 invocation = ToolInvocation(run=run, tool_name=call.name[:64], call_id=call.id[:255], arguments=call.arguments) 

261 try: 

262 if tool is None: 

263 msg = f"Unknown tool {call.name!r}." 

264 raise tools.ToolError(msg) 

265 result = tool.run(call.arguments) 

266 except tools.ToolError as exc: 

267 invocation.status = ToolInvocation.Status.FAILED 

268 invocation.error = str(exc)[:500] 

269 content = json.dumps({"error": invocation.error}) 

270 else: 

271 invocation.status = ToolInvocation.Status.COMPLETED 

272 invocation.provider = result.provider 

273 invocation.result_count = len(result.sources) 

274 run.sources.extend(s for s in result.sources if s not in run.sources) 

275 content = result.content 

276 invocation.latency_ms = int((time.monotonic() - started) * 1000) 

277 invocation.save() 

278 record_event( 

279 conversation=run.conversation, 

280 type=f"tool_{invocation.status}", 

281 run=run, 

282 tool=invocation.tool_name, 

283 invocation=str(invocation.pk), 

284 ) 

285 return content 

286 

287 

288def complete(run: Run, completion: llm.Completion) -> Run: 

289 content = completion.content.strip() 

290 if not content: 

291 return fail(run, code="empty_response", message="The model returned an empty reply.") 

292 with transaction.atomic(): 

293 locked = Run.objects.select_for_update().get(pk=run.pk) 

294 if locked.status != Run.Status.RUNNING: 

295 # Timed out and failed by the sweeper meanwhile; keep that outcome. 

296 return locked 

297 reply = Message.objects.create( 

298 conversation=run.conversation, 

299 role=Message.Role.ASSISTANT, 

300 content=content, 

301 run=run, 

302 sources=getattr(run, "sources", [])[: settings.CHAT_SEARCH_MAX_RESULTS * settings.CHAT_MAX_TOOL_ROUNDS], 

303 ) 

304 run.status = Run.Status.COMPLETED 

305 run.model_name = completion.model or run.model_name 

306 run.usage = completion.usage 

307 run.latency_ms = completion.latency_ms 

308 run.completed_at = timezone.now() 

309 run.save() 

310 record_event( 

311 conversation=run.conversation, 

312 type="run_completed", 

313 run=run, 

314 message=reply, 

315 model=run.model_name, 

316 prompt=run.prompt_version, 

317 latency_ms=completion.latency_ms, 

318 ) 

319 Conversation.objects.filter(pk=run.conversation_id).update(last_activity_at=timezone.now()) 

320 return run 

321 

322 

323def fail(run: Run, *, code: str, message: str) -> Run: 

324 with transaction.atomic(): 

325 locked = Run.objects.select_for_update().get(pk=run.pk) 

326 if locked.status in (Run.Status.COMPLETED, Run.Status.FAILED): 

327 return locked 

328 run.status = Run.Status.FAILED 

329 run.error = {"code": code, "message": message[:500]} 

330 run.completed_at = timezone.now() 

331 run.save() 

332 record_event(conversation=run.conversation, type="run_failed", run=run, code=code) 

333 logger.warning("Chat run %s failed: %s", run.pk, code) 

334 return run 

335 

336 

337def sweep_runs(now=None) -> dict: 

338 """Fail runs stuck too long and re-send queued runs whose task was lost.""" 

339 now = now or timezone.now() 

340 timeout = timedelta(seconds=settings.CHAT_RUN_TIMEOUT_SECONDS) 

341 stats = {"failed": 0, "requeued": 0} 

342 stuck = Run.objects.filter(status=Run.Status.RUNNING, started_at__lt=now - timeout).select_related("conversation") 

343 for run in stuck: 

344 if fail(run, code="timeout", message="No reply in time.").error.get("code") == "timeout": 

345 stats["failed"] += 1 

346 lost = Run.objects.filter(status=Run.Status.QUEUED, created_at__lt=now - timeout).select_related("conversation") 

347 for run in lost: 

348 if run.created_at < now - 3 * timeout: 

349 fail(run, code="not_started", message="The reply was never started.") 

350 stats["failed"] += 1 

351 else: 

352 from .tasks import execute_run 

353 

354 execute_run.delay(str(run.pk)) 

355 stats["requeued"] += 1 

356 return stats 

357 

358 

359# --- Voice messages --------------------------------------------------------- 

360 

361 

362def _enqueue_transcription(clip: AudioClip) -> None: 

363 from .tasks import transcribe_audio 

364 

365 transaction.on_commit(lambda: transcribe_audio.delay(str(clip.pk))) 

366 

367 

368def send_voice(*, conversation: Conversation, audio: bytes, content_type: str, idempotency_key: str) -> Message: 

369 """Store a recording, add an empty user message and queue transcription. 

370 

371 Safe to call twice with one key. Storage errors are let through and 

372 nothing is recorded then. 

373 """ 

374 with transaction.atomic(): 

375 conversation = Conversation.objects.select_for_update().get(pk=conversation.pk) 

376 existing = conversation.messages.filter(idempotency_key=idempotency_key).first() 

377 if existing is not None: 

378 return existing 

379 if is_busy(conversation): 

380 raise ConversationBusyError(BUSY_MESSAGE) 

381 clip_id = uuid.uuid7() 

382 key = storage.save_bytes( 

383 storage.build_key(AUDIO_AREA, f"{clip_id}.{speech.extension_for(content_type)}"), audio 

384 ) 

385 try: 

386 with transaction.atomic(): 

387 message = Message.objects.create( 

388 conversation=conversation, role=Message.Role.USER, content="", idempotency_key=idempotency_key 

389 ) 

390 clip = AudioClip.objects.create( 

391 id=clip_id, 

392 conversation=conversation, 

393 message=message, 

394 storage_key=key, 

395 content_type=content_type, 

396 size_bytes=len(audio), 

397 ) 

398 record_event( 

399 conversation=conversation, 

400 type="audio_received", 

401 message=message, 

402 clip=str(clip.pk), 

403 content_type=content_type, 

404 bytes=len(audio), 

405 ) 

406 conversation.last_activity_at = timezone.now() 

407 conversation.save(update_fields=["last_activity_at"]) 

408 except Exception: 

409 storage.delete(key) 

410 raise 

411 _enqueue_transcription(clip) 

412 return message 

413 

414 

415def retry_transcription(clip: AudioClip) -> AudioClip: 

416 """Queue another attempt at a failed transcription.""" 

417 with transaction.atomic(): 

418 conversation = Conversation.objects.select_for_update().get(pk=clip.conversation_id) 

419 clip = AudioClip.objects.get(pk=clip.pk) 

420 if clip.status != AudioClip.Status.FAILED: 

421 msg = "Only a failed transcription can be retried." 

422 raise RetryNotAllowedError(msg) 

423 if clip.attempt >= settings.CHAT_MAX_ATTEMPTS: 

424 msg = "This message has been tried too many times." 

425 raise RetryNotAllowedError(msg) 

426 if is_busy(conversation): 

427 raise ConversationBusyError(BUSY_MESSAGE) 

428 clip.status = AudioClip.Status.PENDING 

429 clip.attempt += 1 

430 clip.error = {} 

431 clip.queued_at = timezone.now() 

432 clip.started_at = clip.completed_at = None 

433 clip.save() 

434 record_event( 

435 conversation=conversation, 

436 type="transcription_retried", 

437 message=clip.message, 

438 clip=str(clip.pk), 

439 attempt=clip.attempt, 

440 ) 

441 conversation.last_activity_at = timezone.now() 

442 conversation.save(update_fields=["last_activity_at"]) 

443 _enqueue_transcription(clip) 

444 return clip 

445 

446 

447def transcribe(clip_id) -> AudioClip | None: 

448 """Transcribe a pending clip; on success queue the reply.""" 

449 claimed = AudioClip.objects.filter(pk=clip_id, status=AudioClip.Status.PENDING).update( 

450 status=AudioClip.Status.TRANSCRIBING, started_at=timezone.now() 

451 ) 

452 if not claimed: 

453 return None 

454 clip = AudioClip.objects.select_related("conversation", "message").get(pk=clip_id) 

455 provider = speech.get_provider() 

456 try: 

457 if provider is None: 

458 msg = "Voice input is not configured." 

459 raise speech.TranscriptionError(msg, code="not_configured") 

460 audio = storage.read_bytes(clip.storage_key) 

461 if audio is None: 

462 msg = "The recording is missing from storage." 

463 raise speech.TranscriptionError(msg, code="missing_audio") 

464 transcript = provider.transcribe( 

465 audio, content_type=clip.content_type, language_code=clip.conversation.language_code 

466 ) 

467 except speech.TranscriptionError as exc: 

468 return fail_transcription(clip, code=exc.code, message=str(exc)) 

469 except Exception as exc: 

470 logger.exception("Transcription of %s failed", clip.pk) 

471 return fail_transcription(clip, code="internal_error", message=type(exc).__name__) 

472 text = " ".join(transcript.text.split())[: settings.CHAT_MAX_MESSAGE_CHARACTERS] 

473 if not text: 

474 return fail_transcription(clip, code="no_speech", message="No speech was recognised.") 

475 with transaction.atomic(): 

476 conversation = Conversation.objects.select_for_update().get(pk=clip.conversation_id) 

477 locked = AudioClip.objects.select_for_update().get(pk=clip.pk) 

478 if locked.status != AudioClip.Status.TRANSCRIBING: 

479 # Timed out and failed by the sweeper meanwhile; keep that outcome. 

480 return locked 

481 clip.status = AudioClip.Status.TRANSCRIBED 

482 clip.provider = (transcript.provider or getattr(provider, "name", ""))[:128] 

483 clip.language = transcript.language[:24] 

484 clip.duration_seconds = transcript.duration 

485 clip.completed_at = timezone.now() 

486 clip.save() 

487 message = clip.message 

488 message.content = text 

489 message.save(update_fields=["content"]) 

490 record_event( 

491 conversation=conversation, 

492 type="transcription_completed", 

493 message=message, 

494 clip=str(clip.pk), 

495 provider=clip.provider, 

496 characters=len(text), 

497 duration_seconds=clip.duration_seconds, 

498 ) 

499 _queue_reply(conversation, message, via="voice") 

500 return clip 

501 

502 

503def fail_transcription(clip: AudioClip, *, code: str, message: str) -> AudioClip: 

504 with transaction.atomic(): 

505 locked = AudioClip.objects.select_for_update().get(pk=clip.pk) 

506 if locked.status in (AudioClip.Status.TRANSCRIBED, AudioClip.Status.FAILED): 

507 return locked 

508 clip.status = AudioClip.Status.FAILED 

509 clip.error = {"code": code, "message": message[:500]} 

510 clip.completed_at = timezone.now() 

511 clip.save() 

512 record_event( 

513 conversation=clip.conversation, 

514 type="transcription_failed", 

515 message=clip.message, 

516 clip=str(clip.pk), 

517 code=code, 

518 ) 

519 logger.warning("Transcription of %s failed: %s", clip.pk, code) 

520 return clip 

521 

522 

523def sweep_transcriptions(now=None) -> dict: 

524 """Fail transcriptions stuck too long and re-send pending ones whose task was lost.""" 

525 now = now or timezone.now() 

526 timeout = timedelta(seconds=settings.CHAT_RUN_TIMEOUT_SECONDS) 

527 stats = {"failed": 0, "requeued": 0} 

528 stuck = AudioClip.objects.filter(status=AudioClip.Status.TRANSCRIBING, started_at__lt=now - timeout) 

529 for clip in stuck.select_related("conversation"): 

530 if fail_transcription(clip, code="timeout", message="No transcript in time.").error.get("code") == "timeout": 

531 stats["failed"] += 1 

532 lost = AudioClip.objects.filter(status=AudioClip.Status.PENDING, queued_at__lt=now - timeout) 

533 for clip in lost.select_related("conversation"): 

534 if clip.queued_at < now - 3 * timeout: 

535 fail_transcription(clip, code="not_started", message="The transcription was never started.") 

536 stats["failed"] += 1 

537 else: 

538 from .tasks import transcribe_audio 

539 

540 transcribe_audio.delay(str(clip.pk)) 

541 stats["requeued"] += 1 

542 return stats 

543 

544 

545def new_idempotency_key() -> str: 

546 return uuid.uuid4().hex 

547 

548 

549# --- Reports ---------------------------------------------------------------- 

550 

551 

552def report_message(*, message: Message, user, reason: str, note: str = ""): 

553 """Flag an assistant reply for staff. Reporting the same reply twice is a no-op.""" 

554 from .models import ChatFlag 

555 

556 if message.role != Message.Role.ASSISTANT or message.conversation.user_id != user.pk: 

557 msg = "Only replies in your own conversations can be reported." 

558 raise ChatError(msg) 

559 flag, created = ChatFlag.objects.get_or_create( 

560 message=message, 

561 reported_by=user, 

562 defaults={"conversation": message.conversation, "reason": reason, "note": note.strip()[:500]}, 

563 ) 

564 if created: 

565 record_event(conversation=message.conversation, type="reply_reported", message=message, reason=reason) 

566 return flag 

567 

568 

569def decide_flag(flag, status: str, *, reviewer, note: str = "") -> bool: 

570 """Record the one staff decision on a report. Returns False if already decided.""" 

571 from django.urls import reverse 

572 

573 from baibu.notifications.services import notify 

574 

575 from .models import ChatFlag 

576 

577 if status not in {ChatFlag.Status.CONFIRMED, ChatFlag.Status.DISMISSED}: 

578 msg = f"Not a decision: {status}" 

579 raise ValueError(msg) 

580 with transaction.atomic(): 

581 locked = ChatFlag.objects.select_for_update().get(pk=flag.pk) 

582 if locked.status != ChatFlag.Status.OPEN: 

583 return False 

584 locked.status = status 

585 locked.decided_by = reviewer 

586 locked.decided_at = timezone.now() 

587 locked.decision_note = note.strip() 

588 locked.save() 

589 record_event(conversation=locked.conversation, type=f"report_{status}", message=locked.message) 

590 notify( 

591 locked.reported_by, 

592 kind="chat_report_reviewed", 

593 link=reverse("chat:conversation", args=[locked.conversation_id]), 

594 ) 

595 flag.refresh_from_db() 

596 return True