o
    zSojW|                     @  sx  d Z ddlmZ ddlZddlZddlZddlmZ ddlm	Z	m
Z
 ddlmZ ddlmZmZmZmZ ddlmZ dd	lmZmZ dd
lmZ ddlmZmZmZmZmZ ddlm Z m!Z! ddl"m#Z# ddl$m%Z%m&Z& ddl'm(Z(m)Z) ddl*m+Z+m,Z,m-Z- e.e/Z0e
ege	d f Z1e
eege	d f Z2e+dddZ3dZ4dZ5dZ6dZ7dZ8dZ9dZ:dZ;G dd dZ<G dd dZ=dS )u   Orquestación conversacional: RTP → STT → LLM → TTS → RTP.

Los proveedores STT/TTS/LLM se inyectan por factory; cambiar cloud↔local
no requiere tocar este archivo.
    )annotationsN)deque)	AwaitableCallable)Any)BotActionTriggerfind_triggerload_bot_action_triggersstrip_phrase_from_speech)	CallState)Settingsget_settings)create_language_model)ChatMessageConversationContextLanguageModelLlmFatalErrorLlmQuotaExceeded)AmbienceLoopget_ambience_loop)MediaManager)SAMPLES_PER_PACKET
RtpSession)create_speech_to_textcreate_text_to_speech)AudioFormatSpeechToTextTextToSpeechmulawi@  )encodingsample_rate_hziX           u@   Disculpá, no pude escucharte bien. ¿Podés repetir, por favor?uj   Disculpá, en este momento no puedo continuar con la atención automática. Por favor intentá más tarde.u<  INICIO_LLAMADA: Generá el saludo inicial de esta llamada telefónica usando los datos de la póliza ya precargados. Sé breve (1-2 oraciones), saludá por el nombre del cliente si está disponible, mencioná que sos de Qualia seguros y preguntá en qué podés ayudar. No inventes datos que no estén en la póliza.c                   @  s8  e Zd ZdZddddddddZededdZdfddZdfdd Zdgd"d#Z	ded$d%Z
dfd&d'Zdhd*d+Zdfd,d-Zdid/d0Zdjd3d4Zdkd6d7Zdld8d9Zdfd:d;Zdfd<d=Zdfd>d?Zd@dAdmdDdEZdndIdJZd@dKdodNdOZdpdRdSZdfdTdUZdqdVdWZdrdYdZZdfd[d\Zdfd]d^Zdfd_d`ZdsdbdcZdS )tBotConversationSessionuD   Una conversación activa por llamada (turno a turno con VAD simple).N)	on_update	on_actionaction_triggerscallr   rtpr   sttr   ttsr   llmr   settingsr   r%   NotifyFn | Noner&   ActionFn | Noner'   list[BotActionTrigger] | NonereturnNonec          
      C  s   || _ || _|| _|| _|| _|| _|| _|| _|	pg | _d | _	t
 | _g | _d| _d| _d| _d| _d| _d | _d| _d | _d | _d S )NFr   )r(   r)   r*   r+   r,   r-   r%   r&   r'   _taskasyncioEvent_stopped_history	_speaking
_llm_turns_stt_errors_llm_errors_llm_disabled_warmup_task_action_fired	_ambience_ambience_task)
selfr(   r)   r*   r+   r,   r-   r%   r&   r'    rB   >/var/www/html/bot-asterisk-ari/backend/services/bot_session.py__init__J   s*   


zBotConversationSession.__init__boolc                 C  s   | j d uo
| j   S N)r3   donerA   rB   rB   rC   runningm   s   zBotConversationSession.runningc                 C  s6   | j rd S | j  tj|  d| jj d| _d S )Nzbot-name)	rI   r6   clearr4   create_task_runr(   call_idr3   rH   rB   rB   rC   startq   s   
zBotConversationSession.startc                   s   | j   |  I d H  | j}d | _|r/| s/|  z|I d H  W n
 tjy.   Y nw | j}d | _|rS| sU|  z|I d H  W d S  tjyR   Y d S w d S d S rF   )	r6   set_stop_ambiencer=   rG   cancelr4   CancelledErrorr3   )rA   warmuptaskrB   rB   rC   stopy   s,   
zBotConversationSession.stopdict | Nonec              
     s  | j rdS | js| jjsdS z| jjt| j|  t| jjdI dH }W nH tyB } zt	
d| jj| W Y d}~dS d}~w ty] } zt	
d| jj| W Y d}~dS d}~w tyn   t	d| jj Y dS w |r|| j_t	d| jj| |  I dH  |S )zKExtrae resultado estructurado (json_schema) antes de liberar la cadena LLM.N)context
transcriptz2Bot call=%s: extract resultado omitido (cuota): %su)   Bot call=%s: extract resultado falló: %sz/Bot call=%s: extract resultado error inesperadozBot call=%s: resultado=%s)r<   r7   r(   rZ   r,   extract_call_resultlist_conversation_contextr   loggerwarningrO   r   	Exception	exceptioncall_resultinfo_notify)rA   resultexcrB   rB   rC   finalize_resultado   sV   
z)BotConversationSession.finalize_resultadoc                 C  s"   | j   o| jjdvo| jj S )N)endedfailed)r6   is_setr(   statusr)   	is_closedrH   rB   rB   rC   _call_alive   s
   
z"BotConversationSession._call_alivec                   s"   | j r|  | jI d H  d S d S rF   )r%   r(   rH   rB   rB   rC   rd      s   zBotConversationSession._notifystatestrc                 C  s$   || j _| j jdv rd| j _d S d S )N)ringingansweredtalking)r(   agent_staterk   )rA   rn   rB   rB   rC   _set_agent_state   s   z'BotConversationSession._set_agent_statec                   s  | j j}td|| jj| jj| jj| j j zzq| j	ddI d H }| 
 s;W W |  I d H  td|| j d S |si| jj}td||| jj d| j _|  I d H  W W |  I d H  td|| j d S |   |  I d H  | js{| 
 sW W |  I d H  td|| j d S | 
 r| jrn| jtkrtd|t n| d |  I d H  |  I d H }| 
 r| jrn|sq| d	 |  I d H  | |I d H }| 
 r| jrn|d u r|  jd
7  _| jtkrtd|| j d| j _|  I d H  n| tI d H  |   I d H  q|st!d| qd| _td|| | j j"#d|  | j$#t%d|d |  I d H  | &|I d H }| 
 rT| jrUn5|sYq| j j"#d|  | j$#t%d|d | '|I d H  | j(s~| 
 sn|   I d H  | 
 sW n$ t)j*y     t+y   t,d| d| j _|  I d H  Y nw W |  I d H  td|| j d S W |  I d H  td|| j d S |  I d H  td|| j w )Nu>   Bot conversación iniciada call=%s stt=%s tts=%s llm=%s doc=%sg       @timeoutu-   Bot conversación finalizada call=%s turns=%du   Bot call=%s: sin destino RTP hacia Asterisk. EXTERNAL_MEDIA_ADVERTISE_HOST=%s puerto=%s — debe ser la IP de ESTE servidor alcanzable desde Asterisk (UDP abierto). Revisá también UNICASTRTP_LOCAL_* del canal externalMedia.errorz.Bot call=%s: tope de %d turnos LLM; deteniendo	listeningthinking   z/Bot call=%s: %d errores STT seguidos; abortandou   STT vacío call=%sr   zBot STT call=%s: %rzuser: userrolecontentbot: 	assistantu   Bot conversación error call=%s)-r(   rO   r^   rc   r*   provider_namer+   r,   document_id_wait_for_rtprm   rR   r9   r-   external_media_advertise_hostrw   r)   
local_portrs   rd   _start_ambience_speak_openingr<   MAX_LLM_TURNSr_   rt   _capture_utterance_recognize_userr:   MAX_STT_ERRORS_speakSTT_ERROR_PROMPT_post_speak_cooldowndebugrZ   appendr7   r   
_reply_llm_speak_and_maybe_actr>   r4   rT   r`   ra   )rA   rO   rtp_ok	advertise	utterance	user_textbot_textrB   rB   rC   rN      s
  ^
OJ





AzBotConversationSession._runr   c                   s   t || j}|}|r|jst||jp|}| r"| |I dH  |r*| js*| js,dS d| _t	
d| jj|j|j|jp=d tj| |d| jjdd  d dS )uC   TTS del turno; si hay frase trigger, agenda la acción tras hablar.NTu/   Bot call=%s: trigger %r → action=%s target=%s-zbot-action-   rJ   )r   r'   speak_phraser
   phrasestripr   r>   r&   r^   rc   r(   rO   actiontargetr4   rM   _dispatch_action)rA   r   triggerspeechrB   rB   rC   r   ;  s*   

z+BotConversationSession._speak_and_maybe_actr   r   c                   sV   | j sd S z|  | j|I d H  W d S  ty*   td| jj|j d| _Y d S w )Nu   Bot call=%s: falló acción %sF)r&   r(   r`   r^   ra   rO   r   r>   )rA   r   rB   rB   rC   r   V  s   z'BotConversationSession._dispatch_actionr   c                 C  s    t | jj| jj| jj| jjdS )N)rO   r   caller_numberpoliza)r   r(   rO   r   numberpoliza_datarH   rB   rB   rC   r]   c  s   z,BotConversationSession._conversation_contextc                 C  s<   | j jpi }	 |dpd}|rd| dS | jjpd S )uF   Saludo TTS sin LLM: usa nombre CRM si hay póliza, si no BOT_GREETING.nombre zHola u:   , soy Mariano de Qualia seguros. ¿En qué puedo ayudarte?)r(   r   getr-   bot_greetingr   )rA   datar   rB   rB   rC   _build_immediate_greetingk  s   
z0BotConversationSession._build_immediate_greetingc                   sv    j s jd ur
d S  jjpd  dkrd S d fdd}tj| d jj	d d  d	 _t
d
 jj	 d S )Nr   ollamar1   r2   c                     s>   z j  I d H  W d S  ty   td jj Y d S w )Nu   Bot call=%s: warmup LLM falló)r,   rU   r`   r^   ra   r(   rO   rB   rH   rB   rC   rN     s   
z6BotConversationSession._start_llm_warmup.<locals>._runzllm-warmup-r   rJ   z0Bot call=%s: warmup Ollama en paralelo al saludor1   r2   )r<   r=   r-   llm_providerr   lowerr4   rM   r(   rO   r^   rc   )rA   rN   rB   rH   rC   _start_llm_warmup}  s   	z(BotConversationSession._start_llm_warmupc                   sB   | j }|d u s| rd S z|I d H  W d S  ty    Y d S w rF   )r=   rG   r`   rA   rV   rB   rB   rC   _await_warmup  s   z$BotConversationSession._await_warmupc                   sv  | j jpd  }|dkrt| jjrt| jsttd| jj	| jjp!i 
d | d |  I dH  | jtddI dH }| jsC|  sEdS |rl| jjd	|  | jtd
|d | |I dH  |  I dH  dS td| jj	 | jr}|dkr}dS |   |  }|rtd| jj	|dd  | jjd	|  | jtd
|d | |I dH  |  I dH  dS dS )zSaludo inicial.

        Por defecto (BOT_OPENING_MODE=immediate): TTS ya con nombre CRM /
        BOT_GREETING, y warmup de Ollama en paralelo.
        Con mode=llm: espera el saludo generado por el modelo (comportamiento viejo).
        	immediater,   u3   Bot call=%s: saludo inicial vía LLM con póliza %sr   ry   NFrecord_userr   r   r|   u:   Bot call=%s: LLM no devolvió saludo; uso saludo inmediatoz Bot call=%s: saludo inmediato %rx   )r-   bot_opening_moder   r   r(   r   r<   r^   rc   rO   r   rt   rd   r   OPENING_PROMPTrm   rZ   r   r7   r   r   r   r_   r   r   )rA   moder   greetingrB   rB   rC   r     sV   


z%BotConversationSession._speak_openingT)speakreasonr   c                  sp   | j rd S d| _ d| j_td| jj| |  I d H  |r4|  r6| t	I d H  | 
 I d H  d S d S d S )NTrw   u%   Bot call=%s: LLM deshabilitado — %s)r<   r(   rs   r^   rw   rO   rd   rm   r   QUOTA_ERROR_PROMPTr   )rA   r   r   rB   rB   rC   _disable_llm  s   z#BotConversationSession._disable_llmr   bytes
str | Nonec                   sP   z| j j|tdI dH }W n ty    td| jj Y dS w |jp%d	 S )zBSTT del turno. ``None`` = error del proveedor; ``""`` = sin texto.audio_formatNu9   Bot STT falló call=%s — se omite LLM/OpenAI este turnor   )
r*   	recognize	TELEPHONYr`   r^   ra   r(   rO   textr   )rA   r   re   rB   rB   rC   r     s   z&BotConversationSession._recognize_userr   r   r   c             
     s  | j rdS |r		 t| jt d }t fdd|D s'|td dg }|  jd7  _td| j	j
| jt|t| j	j |  I dH  z| jj||  d	I dH }W nd tyt } z| d
| I dH  W Y d}~dS d}~w ty } z| d| I dH  W Y d}~dS d}~w ty   |  jd7  _td| j	j
 | jtkr| | j dI dH  Y dS w |jpd }|rd| _|S )u*   Solo se invoca con transcripción válida.r   Nc                 3  s$    | ]}|j d ko|j kV  qdS )r{   Nr|   ).0mr   rB   rC   	<genexpr>  s    
z4BotConversationSession._reply_llm.<locals>.<genexpr>r{   r|   rz   z4Bot LLM request call=%s turn=%d history=%d poliza=%s)rY   zcuota OpenAI: zerror fatal LLM: u   Bot LLM falló call=%sz fallos LLM consecutivosr   )r<   r\   r7   MAX_HISTORY_MESSAGESanyr   r9   r^   rc   r(   rO   lenrE   r   r   r,   replyr]   r   r   r   r`   r;   ra   MAX_LLM_ERRORSr   r   )rA   r   r   history_for_llmr   rf   r   rB   r   rC   r     s^   




z!BotConversationSession._reply_llmrv   floatc                   s   | j jr(| j jtd|dI d H }|rtd| jj dS td| jj dS t	 
 | }|  rg|t	 
  }|dkrJtd| jj dS | j jtd	|dI d H }|rctd| jj dS |  s4dS )
Ng      ?ru   z,Bot call=%s: RTP recibido (listo para audio)um   Bot call=%s: sin RTP entrante aún (habitual en softphone con silence suppression); se usa UNICASTRTP_LOCAL_*Tr   z.Bot call=%s: timeout esperando RTP de AsteriskF      ?)r)   
has_remoterecv_ulaw_frameminr^   rc   r(   rO   r4   get_running_looptimerm   r_   )rA   rv   framedeadline	remainingrB   rB   rC   r     s@   
z$BotConversationSession._wait_for_rtpc                   sd   t   td  }|  r0|t    }|dkrdS | jjtd|dI dH  |  sdS dS )zADescarta frames entrantes para no transcribir eco del propio TTS.g     @@r   g?ru   N)r4   r   r   POST_SPEAK_COOLDOWN_MSrm   r)   r   r   )rA   r   r   rB   rB   rC   r   B  s   z+BotConversationSession._post_speak_cooldownc                   s  t dt| jjd }t dt| jjd }t |t| jjd }| jj}t dt| jjd }t|dkr8|ndd}t	 }d}d}	d}
d}d}t
  }|  r| jjddI dH }|du r|s}|
s}|dkr}t
  | d	kr}d
}td| jj |
r|	|krnoqO|d7 }| jr|  qOt|}t|d}||k}|
s|dkr|| |rd
}
d}d}	|r|d| n|| |  qO|r|d7 }d}	|| n|	d7 }	|| |	|kr||krn||	 |krn|  sS||krt|S dS )u   VAD por energía: silencia → fin de turno.

        Mantiene un pre-roll de frames previos al umbral para no cortar
        el ataque de la primera palabra (común en STT telefónico).
        rz   r!   r   )maxlenFr   ru   Ng      @Tu   Bot call=%s: 5s escuchando sin RTP del puente — Asterisk no está recibiendo audio del caller (NAT típico en datos móviles: falta external_media_address/local_net en el transport PJSIP, rtp_symmetric=yes, o UDP RTP abierto en firewall)r#       )maxintr-   bot_silence_msbot_min_speech_msbot_max_utterance_msbot_vad_energybot_preroll_msr   	bytearrayr4   r   r   rm   r)   r   r^   r_   r(   rO   r8   rL   r   ulaw_to_pcmaudiooprmsr   extendjoinr   )rA   silence_framesmin_speech_frames
max_framesenergy_thresholdpreroll_framesprerollbufspeech_seensilence_seenstartedframes_seenno_rtp_warnedlisten_startedr   pcmr   	is_speechrB   rB   rC   r   M  s   



<z)BotConversationSession._capture_utterancer   c                   sx   |   sd S | d |  I d H  | jj|tdI d H }|r$|   s&d S d| _z| |I d H  W d| _d S d| _w )Nspeakingr   TF)rm   rt   rd   r+   
synthesizer   r8   
_play_ulaw)rA   r   audiorB   rB   rC   r     s   
zBotConversationSession._speakc                 C  s   | j jsd S | jd urd S | j | j j}|s$td| jj| j j d S zt	|| j j
d| _W n tyB   td| jj| Y d S w tj|  d| jjd d  d| _td| jj|j| j j
| jj d S )Nz(Bot call=%s: ambience no encontrado (%s))gainz*Bot call=%s: no se pudo cargar ambience %sz	ambience-r   rJ   z7Bot call=%s: ambience ON file=%s gain=%.2f (%.1fs loop))r-   bot_ambience_enabledr@   resolve_prompt_pathbot_ambience_filer^   r_   r(   rO   r   bot_ambience_gainr?   r`   ra   r4   rM   _ambience_looprc   rK   
duration_s)rA   pathrB   rB   rC   r     sF   
z&BotConversationSession._start_ambiencec                   sX   | j }d | _ d | _|r(| s*|  z|I d H  W d S  tjy'   Y d S w d S d S rF   )r@   r?   rG   rS   r4   rT   r   rB   rB   rC   rR     s   z%BotConversationSession._stop_ambiencec                   s   |   rB| js| jdu rtdI dH  qz| j| j  W n ty3   t	
d| jj Y dS w tdI dH  |   sdS dS )uC   Envía ambiente en loop mientras no hay TTS (el TTS mezcla encima).N{Gz?z$Bot call=%s: error enviando ambience)rm   r8   r?   r4   sleepr)   	send_ulaw
next_framer`   r^   ra   r(   rO   rH   rB   rB   rC   r    s    z%BotConversationSession._ambience_loopulawc                   sl   t dt|tD ]*}|  s d S |||t  }| jd ur%| j|}| j| t	dI d H  q	d S )Nr   r  )
ranger   r   rm   r?   mix_with_ttsr)   r  r4   r  )rA   r	  offsetchunkrB   rB   rC   r     s   
z!BotConversationSession._play_ulaw)r(   r   r)   r   r*   r   r+   r   r,   r   r-   r   r%   r.   r&   r/   r'   r0   r1   r2   )r1   rE   r   )r1   rX   )rn   ro   r1   r2   )r   ro   r1   r2   )r   r   r1   r2   )r1   r   )r1   ro   )r   ro   r   rE   r1   r2   )r   r   r1   r   )r   ro   r   rE   r1   ro   )rv   r   r1   rE   )r1   r   )r   ro   r1   r2   )r	  r   r1   r2   ) __name__
__module____qualname____doc__rD   propertyrI   rP   rW   rg   rm   rd   rt   rN   r   r   r]   r   r   r   r   r   r   r   r   r   r   r   r   rR   r  r   rB   rB   rB   rC   r$   G   sD    #



*



t





	3

1
#

\

$
r$   c                   @  s|   e Zd ZdZ			d)d*ddZd+ddZd,ddZd-ddZd.ddZd/ddZ	d0dd Z
d1d"d#Zd/d$d%Zd2d'd(ZdS )3BotSessionManagerz'Administra sesiones de bot por call_id.Nmedia_managerr   r-   Settings | Noner%   r.   r&   r/   r1   r2   c                 C  sF   || _ |pt | _|| _|| _i | _d | _d | _d | _t	| j| _
d S rF   )r  r   r-   r%   r&   	_sessions_stt_tts_llmr	   _action_triggers)rA   r  r-   r%   r&   rB   rB   rC   rD     s   zBotSessionManager.__init__fnc                 C  
   || _ d S rF   )r%   rA   r  rB   rB   rC   set_on_update     
zBotSessionManager.set_on_updatec                 C  r  rF   )r&   r  rB   rB   rC   set_on_action  r  zBotSessionManager.set_on_action0tuple[SpeechToText, TextToSpeech, LanguageModel]c                 C  sR   | j d u rt| j| _ | jd u rt| j| _| jd u r!t| j| _| j | j| jfS rF   )r  r   r-   r  r   r  r   rH   rB   rB   rC   _ensure_providers  s   


z#BotSessionManager._ensure_providersr(   r   c                   s   | j jstd|j d S |j| jv r| j|j jrd S | j|j}|d u r1t	d|j d| 
 \}}}t|||||| j | j| j| jd	}|| j|j< |  d S )Nz-Bot deshabilitado (BOT_ENABLED=false) call=%su   Sin sesión RTP para bot call=z1; attach_bot_external_media debe ejecutarse antes)r*   r+   r,   r-   r%   r&   r'   )r-   bot_enabledr^   rc   rO   r  rI   r  get_rtp_sessionRuntimeErrorr"  r$   r%   r&   r  rP   )rA   r(   r)   r*   r+   r,   sessionrB   rB   rC   start_for_call%  s2   z BotSessionManager.start_for_callc                   sT   | j jsdS | j jpd  dkrdS |  \}}}td | I dH  dS )z0Precarga el LLM (Ollama) al arrancar el backend.Nr   r   z%Warmup LLM al startup provider=ollama)	r-   r#  r   r   r   r"  r^   rc   rU   )rA   _r,   rB   rB   rC   
warmup_llmB  s   
zBotSessionManager.warmup_llmrO   ro   c                   s   | j |d }|r*z	| I d H  W n ty"   td| Y nw | I d H  | jd urIt| jdrKz	| j	| W d S  tyH   Y d S w d S d S )Nz)Bot call=%s: fallo al finalizar resultadodrop_thread)
r  poprg   r`   r^   ra   rW   r  hasattrr*  rA   rO   r&  rB   rB   rC   stop_for_callL  s$   zBotSessionManager.stop_for_callrE   c                 C  s   | j |}|d uo|jS rF   )r  r   rI   r-  rB   rB   rC   
is_running^  s   zBotSessionManager.is_runningc                   s   t | jD ]
}| |I d H  q| j| j| jfD ](}|d urAz	| I d H  W q ty@ } zt	d| W Y d }~qd }~ww qd  | _ | _| _d S )NzCierre provider: %s)
r\   r  r.  r  r  r  closer`   r^   r   )rA   rO   providerrf   rB   rB   rC   r0  b  s   zBotSessionManager.closedict[str, Any]c                 C  s0   | j j| j j| j j| j jdd | j D dS )Nc                 S  s   g | ]	\}}|j r|qS rB   )rI   )r   cidsrB   rB   rC   
<listcomp>s  s
    
z,BotSessionManager.status.<locals>.<listcomp>)enabledstt_providertts_providerr   active_sessions)r-   r#  r7  r8  r   r  itemsrH   rB   rB   rC   rk   m  s   zBotSessionManager.status)NNN)
r  r   r-   r  r%   r.   r&   r/   r1   r2   )r  r.   r1   r2   )r  r/   r1   r2   )r1   r!  )r(   r   r1   r2   r   )rO   ro   r1   r2   )rO   ro   r1   rE   )r1   r2  )r  r  r  r  rD   r  r   r"  r'  r)  r.  r/  r0  rk   rB   rB   rB   rC   r    s    



	




r  )>r  
__future__r   r4   r   loggingcollectionsr   collections.abcr   r   typingr   bot_actionsr   r   r	   r
   calls.modelsr   configr   r   llm.factoryr   llm.protocolsr   r   r   r   r   media.ambiancer   r   media.managerr   media.rtp_sessionr   r   voice.factoryr   r   voice.protocolsr   r   r   	getLoggerr  r^   NotifyFnActionFnr   r   r   r   r   r   r   r   r   r$   r  rB   rB   rB   rC   <module>   sP    
	     @