""" 言迹智能工牌API服务 """ import logging import random from datetime import datetime, timedelta from typing import Dict, List, Optional, Any import httpx from app.core.config import settings logger = logging.getLogger(__name__) class YanjiService: """言迹智能工牌API服务类""" def __init__(self): self.base_url = settings.YANJI_API_BASE self.client_id = settings.YANJI_CLIENT_ID self.client_secret = settings.YANJI_CLIENT_SECRET self.tenant_id = settings.YANJI_TENANT_ID self.estate_id = int(settings.YANJI_ESTATE_ID) # Token缓存 self._access_token: Optional[str] = None self._token_expires_at: Optional[datetime] = None async def get_access_token(self) -> str: """ 获取或刷新access_token Returns: access_token字符串 """ # 检查缓存的token是否仍然有效(提前5分钟刷新) if self._access_token and self._token_expires_at: if datetime.now() < self._token_expires_at - timedelta(minutes=5): return self._access_token # 获取新的token url = f"{self.base_url}/oauth/token" params = { "grant_type": "client_credentials", "client_id": self.client_id, "client_secret": self.client_secret, } async with httpx.AsyncClient() as client: response = await client.get(url, params=params, timeout=30.0) response.raise_for_status() data = response.json() self._access_token = data["access_token"] expires_in = data.get("expires_in", 3600) # 默认1小时 self._token_expires_at = datetime.now() + timedelta(seconds=expires_in) logger.info(f"言迹API token获取成功,有效期至: {self._token_expires_at}") return self._access_token async def _request( self, method: str, path: str, params: Optional[Dict] = None, json_data: Optional[Dict] = None, ) -> Dict: """ 统一的HTTP请求方法 Args: method: HTTP方法(GET/POST等) path: API路径 params: Query参数 json_data: Body参数(JSON) Returns: 响应数据(data字段) Raises: Exception: API调用失败 """ token = await self.get_access_token() url = f"{self.base_url}{path}" headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient() as client: response = await client.request( method=method, url=url, params=params, json=json_data, headers=headers, timeout=60.0, ) response.raise_for_status() result = response.json() # 言迹API: code='0'或code=0表示成功 code = result.get("code") if str(code) != '0': error_msg = result.get("msg", "Unknown error") logger.error(f"言迹API调用失败: {error_msg}, result={result}") raise Exception(f"言迹API错误: {error_msg}") # data可能为None,返回空字典或空列表由调用方判断 return result.get("data") async def get_visit_audios( self, external_visit_ids: List[str] ) -> List[Dict]: """ 根据来访单ID获取录音信息 Args: external_visit_ids: 三方来访ID列表(最多10个) Returns: 录音信息列表 """ if not external_visit_ids: return [] if len(external_visit_ids) > 10: logger.warning(f"来访单ID数量超过限制,截取前10个") external_visit_ids = external_visit_ids[:10] data = await self._request( method="POST", path="/api/beauty/v1/visit/audios", json_data={ "estateId": self.estate_id, "externalVisitIds": external_visit_ids, }, ) if data is None: logger.info(f"获取来访录音信息: 无数据") return [] records = data.get("records", []) logger.info(f"获取来访录音信息成功: {len(records)}条") return records async def get_audio_asr_result(self, audio_id: int) -> Dict: """ 获取录音的ASR分析结果(对话文本) Args: audio_id: 录音ID Returns: ASR分析结果,包含对话文本数组 """ data = await self._request( method="GET", path="/api/beauty/v1/audio/asr-analysed", params={"estateId": self.estate_id, "audioId": audio_id}, ) # 检查data是否为None if data is None: logger.warning(f"录音ASR结果为None: audio_id={audio_id}") return {} # data是一个数组,取第一个元素 if isinstance(data, list) and len(data) > 0: result = data[0] logger.info( f"获取录音ASR结果成功: audio_id={audio_id}, " f"对话数={len(result.get('result', []))}" ) return result else: logger.warning(f"录音ASR结果为空: audio_id={audio_id}") return {} async def get_recent_conversations( self, consultant_phone: str, limit: int = 10 ) -> List[Dict]: """ 获取员工最近N条对话记录 业务逻辑: 1. 通过员工手机号获取录音列表(目前使用模拟数据) 2. 对每个录音获取ASR分析结果 3. 组合返回完整的对话记录 Args: consultant_phone: 员工手机号 limit: 获取数量,默认10条 Returns: 对话记录列表,格式: [{ "audio_id": 123, "visit_id": "xxx", "start_time": "2025-01-15 10:30:00", "duration": 120000, "consultant_name": "张三", "consultant_phone": "13800138000", "conversation": [ {"role": "consultant", "text": "您好..."}, {"role": "customer", "text": "你好..."} ] }] """ # TODO: 目前言迹API没有直接通过手机号查询录音的接口 # 需要先获取来访单列表,再获取录音 # 这里暂时返回空列表,后续根据实际业务需求补充 logger.warning( f"获取员工对话记录功能需要额外的业务逻辑支持 " f"(consultant_phone={consultant_phone}, limit={limit})" ) # 返回空列表,表示暂未实现 return [] async def get_conversations_by_visit_ids( self, external_visit_ids: List[str] ) -> List[Dict]: """ 根据来访单ID列表获取对话记录 Args: external_visit_ids: 三方来访ID列表 Returns: 对话记录列表 """ if not external_visit_ids: return [] # 1. 获取录音信息 audio_records = await self.get_visit_audios(external_visit_ids) if not audio_records: logger.info("没有找到录音记录") return [] # 2. 对每个录音获取ASR分析结果 conversations = [] for audio in audio_records: audio_id = audio.get("id") if not audio_id: continue try: asr_result = await self.get_audio_asr_result(audio_id) # 解析对话文本 conversation_messages = [] for item in asr_result.get("result", []): role = "consultant" if item.get("role") == -1 else "customer" conversation_messages.append({ "role": role, "text": item.get("text", ""), "begin_time": item.get("beginTime"), "end_time": item.get("endTime"), }) # 组合完整对话记录 conversations.append({ "audio_id": audio_id, "visit_id": audio.get("externalVisitId", ""), "start_time": audio.get("startTime", ""), "duration": audio.get("duration", 0), "consultant_name": audio.get("consultantName", ""), "consultant_phone": audio.get("consultantPhone", ""), "conversation": conversation_messages, }) except Exception as e: logger.error(f"获取录音ASR结果失败: audio_id={audio_id}, error={e}") continue logger.info(f"成功获取{len(conversations)}条对话记录") return conversations async def get_audio_list(self, phone: str) -> List[Dict]: """ 获取员工的录音列表(模拟) 注意:言迹API暂时没有提供通过手机号直接查询录音列表的接口 这里使用模拟数据,返回假想的录音列表 Args: phone: 员工手机号 Returns: 录音信息列表 """ logger.info(f"获取员工录音列表(模拟): phone={phone}") # 模拟返回10条录音记录 mock_audios = [] base_time = datetime.now() for i in range(10): # 模拟不同时长的录音 durations = [25000, 45000, 180000, 240000, 120000, 90000, 60000, 300000, 420000, 150000] mock_audios.append({ "id": f"mock_audio_{i+1}", "externalVisitId": f"visit_{i+1}", "startTime": (base_time - timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"), "duration": durations[i], # 毫秒 "consultantName": "模拟员工", "consultantPhone": phone }) return mock_audios async def get_employee_conversations_for_analysis( self, phone: str, limit: int = 10 ) -> List[Dict[str, Any]]: """ 获取员工最近N条录音的模拟对话数据(用于能力分析) Args: phone: 员工手机号 limit: 获取数量,默认10条 Returns: 对话数据列表,格式: [{ "audio_id": "mock_audio_1", "duration_seconds": 25, "start_time": "2025-10-15 10:30:00", "dialogue_history": [ {"speaker": "consultant", "content": "您好..."}, {"speaker": "customer", "content": "你好..."} ] }] """ # 1. 获取录音列表 audios = await self.get_audio_list(phone) if not audios: logger.warning(f"未找到员工的录音记录: phone={phone}") return [] # 2. 筛选前limit条 selected_audios = audios[:limit] # 3. 为每条录音生成模拟对话 conversations = [] for audio in selected_audios: conversation = self._generate_mock_conversation(audio) conversations.append(conversation) logger.info(f"生成模拟对话数据: phone={phone}, count={len(conversations)}") return conversations def _generate_mock_conversation(self, audio: Dict) -> Dict: """ 为录音生成模拟对话数据 根据录音时长选择不同复杂度的对话模板: - <30秒: 短对话(4-6轮) - 30秒-5分钟: 中等对话(8-12轮) - >5分钟: 长对话(15-20轮,完整销售流程) Args: audio: 录音信息字典 Returns: 对话数据字典 """ duration = int(audio.get('duration', 60000)) // 1000 # 转换为秒 # 根据时长选择对话模板 if duration < 30: dialogue = self._short_conversation_template() elif duration < 300: dialogue = self._medium_conversation_template() else: dialogue = self._long_conversation_template() return { "audio_id": audio.get('id'), "duration_seconds": duration, "start_time": audio.get('startTime'), "dialogue_history": dialogue } def _short_conversation_template(self) -> List[Dict]: """短对话模板(<30秒)- 4-6轮对话""" templates = [ [ {"speaker": "consultant", "content": "您好,欢迎光临曼尼斐绮,请问有什么可以帮到您?"}, {"speaker": "customer", "content": "你好,我想了解一下面部护理项目"}, {"speaker": "consultant", "content": "好的,我们有多种面部护理方案,请问您主要关注哪方面呢?"}, {"speaker": "customer", "content": "主要是想改善皮肤暗沉"}, {"speaker": "consultant", "content": "明白了,针对皮肤暗沉,我推荐我们的美白焕肤套餐"} ], [ {"speaker": "consultant", "content": "您好,请问需要什么帮助吗?"}, {"speaker": "customer", "content": "我想咨询一下祛斑项目"}, {"speaker": "consultant", "content": "好的,请问您主要是哪种类型的斑点呢?"}, {"speaker": "customer", "content": "脸颊两侧有些黄褐斑"}, {"speaker": "consultant", "content": "了解,我们有专门针对黄褐斑的光子嫩肤项目,效果很不错"} ], [ {"speaker": "consultant", "content": "欢迎光临,有什么可以帮您的吗?"}, {"speaker": "customer", "content": "我想预约做个面部护理"}, {"speaker": "consultant", "content": "好的,请问您之前做过我们的项目吗?"}, {"speaker": "customer", "content": "没有,第一次来"}, {"speaker": "consultant", "content": "那我建议您先做个免费的皮肤检测,帮您制定个性化方案"}, {"speaker": "customer", "content": "好的,那现在可以吗?"} ] ] return random.choice(templates) def _medium_conversation_template(self) -> List[Dict]: """中等对话模板(30秒-5分钟)- 8-12轮对话""" templates = [ [ {"speaker": "consultant", "content": "您好,欢迎光临曼尼斐绮,我是美容顾问小王,请问怎么称呼您?"}, {"speaker": "customer", "content": "你好,我姓李"}, {"speaker": "consultant", "content": "李女士您好,请问今天是第一次了解我们的项目吗?"}, {"speaker": "customer", "content": "是的,之前在网上看到你们的介绍"}, {"speaker": "consultant", "content": "好的,您对哪方面的美容项目比较感兴趣呢?"}, {"speaker": "customer", "content": "我想改善面部松弛的问题,最近感觉皮肤没有以前紧致了"}, {"speaker": "consultant", "content": "我理解您的困扰。请问您多大年龄?平时有做面部护理吗?"}, {"speaker": "customer", "content": "我35岁,平时就是用护肤品,没做过专业护理"}, {"speaker": "consultant", "content": "明白了。35岁开始注重抗衰是很及时的。我们有几种方案,比如射频紧肤、超声刀提拉,还有胶原蛋白再生项目"}, {"speaker": "customer", "content": "这几种有什么区别吗?"}, {"speaker": "consultant", "content": "射频主要是刺激胶原蛋白增生,效果温和持久。超声刀作用更深层,提拉效果更明显但价格稍高。我建议您先做个皮肤检测,看具体适合哪种"}, {"speaker": "customer", "content": "好的,那先做个检测吧"} ], [ {"speaker": "consultant", "content": "您好,欢迎光临,我是美容顾问晓雯,请问您是第一次来吗?"}, {"speaker": "customer", "content": "是的,朋友推荐过来看看"}, {"speaker": "consultant", "content": "太好了,请问您朋友是做的什么项目呢?"}, {"speaker": "customer", "content": "她做的好像是什么水光针"}, {"speaker": "consultant", "content": "水光针确实是我们很受欢迎的项目。请问您今天主要想了解哪方面呢?"}, {"speaker": "customer", "content": "我主要是皮肤有点粗糙,毛孔也大"}, {"speaker": "consultant", "content": "嗯,针对毛孔粗大和皮肤粗糙,水光针确实有不错的效果。不过我建议先看看您的具体情况"}, {"speaker": "customer", "content": "需要检查吗?"}, {"speaker": "consultant", "content": "是的,我们有专业的皮肤检测仪,可以看到肉眼看不到的皮肤问题,这样制定方案更精准"}, {"speaker": "customer", "content": "好的,那检查一下吧"}, {"speaker": "consultant", "content": "好的,请这边来,检查大概需要5分钟"} ] ] return random.choice(templates) def _long_conversation_template(self) -> List[Dict]: """长对话模板(>5分钟)- 15-20轮对话,完整销售流程""" templates = [ [ {"speaker": "consultant", "content": "您好,欢迎光临曼尼斐绮,我是资深美容顾问晓雯,请问怎么称呼您?"}, {"speaker": "customer", "content": "你好,我姓陈"}, {"speaker": "consultant", "content": "陈女士您好,看您气色很好,平时应该很注重保养吧?"}, {"speaker": "customer", "content": "还好吧,基本的护肤品会用"}, {"speaker": "consultant", "content": "这样啊。那今天是专程过来了解我们的项目,还是朋友推荐的呢?"}, {"speaker": "customer", "content": "我闺蜜在你们这做过,说效果不错,所以想来看看"}, {"speaker": "consultant", "content": "太好了,请问您闺蜜做的是什么项目呢?"}, {"speaker": "customer", "content": "好像是什么光子嫩肤"}, {"speaker": "consultant", "content": "明白了,光子嫩肤确实是我们的明星项目。不过每个人的皮肤状况不同,我先帮您做个详细的皮肤检测,看看最适合您的方案好吗?"}, {"speaker": "customer", "content": "好的"}, {"speaker": "consultant", "content": "陈女士,通过检测我看到您的皮肤主要有三个问题:一是T区毛孔粗大,二是两颊有轻微色斑,三是皮肤缺水。您平时有感觉到这些问题吗?"}, {"speaker": "customer", "content": "对,毛孔确实有点大,色斑是最近才发现的"}, {"speaker": "consultant", "content": "嗯,这些问题如果不及时处理会越来越明显。针对您的情况,我建议做一个综合性的美白嫩肤方案"}, {"speaker": "customer", "content": "具体是怎么做的?"}, {"speaker": "consultant", "content": "我们采用光子嫩肤配合水光针的组合疗程。光子嫩肤主要解决色斑和毛孔问题,水光针补水锁水,效果相辅相成"}, {"speaker": "customer", "content": "听起来不错,大概需要多少钱?"}, {"speaker": "consultant", "content": "我们现在正好有活动,光子嫩肤单次原价3800,水光针单次2600,组合套餐优惠后只要5800,相当于打了九折"}, {"speaker": "customer", "content": "嗯...还是有点贵"}, {"speaker": "consultant", "content": "我理解您的顾虑。但是陈女士,您想想,这个价格是一次性投入,效果却能维持3-6个月。平均下来每天不到30块钱,换来的是皮肤的明显改善"}, {"speaker": "customer", "content": "这倒也是..."}, {"speaker": "consultant", "content": "而且这个活动就到本月底,下个月恢复原价的话就要6400了。您今天如果确定的话,我还可以帮您申请赠送一次基础补水护理"}, {"speaker": "customer", "content": "那行吧,今天就定了"}, {"speaker": "consultant", "content": "太好了!陈女士您做了个很明智的决定。我现在帮您预约最近的时间,您看周三下午方便吗?"} ], [ {"speaker": "consultant", "content": "您好,欢迎光临,我是美容顾问小张,请问您贵姓?"}, {"speaker": "customer", "content": "我姓王"}, {"speaker": "consultant", "content": "王女士您好,请坐。今天想了解什么项目呢?"}, {"speaker": "customer", "content": "我想做个面部提升,感觉脸有点下垂了"}, {"speaker": "consultant", "content": "嗯,我看得出来您平时很注重保养。请问您今年多大年龄?"}, {"speaker": "customer", "content": "我42了"}, {"speaker": "consultant", "content": "42岁这个年龄段,确实容易出现轻微松弛。您之前有做过抗衰项目吗?"}, {"speaker": "customer", "content": "做过几次普通的面部护理,但感觉效果不明显"}, {"speaker": "consultant", "content": "普通护理主要是表层保养,对于松弛问题作用有限。您的情况需要更深层的治疗"}, {"speaker": "customer", "content": "那有什么好的方案吗?"}, {"speaker": "consultant", "content": "针对您的情况,我推荐热玛吉或者超声刀。这两种都是通过热能刺激深层胶原蛋白重组,达到紧致提升的效果"}, {"speaker": "customer", "content": "这两种有什么区别?"}, {"speaker": "consultant", "content": "热玛吉作用在真皮层,效果更自然持久,适合轻中度松弛。超声刀能到达筋膜层,提拉力度更强,适合松弛比较明显的情况"}, {"speaker": "customer", "content": "我的情况适合哪种?"}, {"speaker": "consultant", "content": "从您的面部状况来看,我建议选择热玛吉。您的松弛程度属于轻度,热玛吉的效果会更自然,恢复期也更短"}, {"speaker": "customer", "content": "费用大概多少?"}, {"speaker": "consultant", "content": "热玛吉全脸的话,我们的价格是28800元。不过您今天来的时机很好,我们正在做周年庆活动,可以优惠到23800"}, {"speaker": "customer", "content": "还是挺贵的啊"}, {"speaker": "consultant", "content": "王女士,我理解您的感受。但是热玛吉一次治疗效果可以维持2-3年,平均每天只要20多块钱。而且这是一次性投入,不需要反复做"}, {"speaker": "customer", "content": "效果真的能维持那么久吗?"}, {"speaker": "consultant", "content": "这是有科学依据的。热玛吉刺激的是您自身的胶原蛋白再生,不是外来填充,所以效果持久自然。我们有很多客户都做过,反馈都很好"}, {"speaker": "customer", "content": "那我考虑一下吧"}, {"speaker": "consultant", "content": "可以的。不过这个活动优惠就到本周日,下周就恢复原价了。而且名额有限,您要是确定的话最好尽快预约"}, {"speaker": "customer", "content": "好吧,那我今天就定下来吧"} ] ] return random.choice(templates)