knight-gt/ai-guide-cloud
1
1import asyncio2from collections import Counter3import hashlib4import hmac5import os6import sqlite37import uuid8from pathlib import Path9 10 11DATA_DIR = Path(__file__).resolve().parent / "data"12DB_PATH = DATA_DIR / "user_data.db"13DEFAULT_AVATAR_ID = "guide3"14DEFAULT_VOICE_ID = "zh-CN-XiaoxiaoNeural"15PASSWORD_ITERATIONS = 120_00016 17def _dedupe_terms(*terms: str) -> tuple[str, ...]:18 return tuple(dict.fromkeys(term for term in terms if term))19 20 21POSITIVE_SENTIMENT_KEYWORDS = _dedupe_terms(22 "喜欢", "漂亮", "震撼", "方便", "热情", "贴心", "干净", "满意",23 "值得", "舒服", "清楚", "专业", "不错", "顺利", "惊喜", "周到",24 "亲切", "负责", "细致", "耐心", "友好", "温柔", "温和", "礼貌",25 "高效", "快速", "准确", "清晰", "明确", "醒目", "直观", "实用",26 "合理", "顺畅", "便捷",27 "省心", "安心", "安全", "规范", "有序", "整洁", "清爽", "清新",28 "优美", "壮观", "精彩", "生动", "详细", "丰富", "有趣", "好玩",29 "好看", "出片", "舒适", "愉快", "开心", "轻松", "放松", "便利",30 "齐全", "完善", "充足", "明亮", "宽敞", "好找", "稳妥", "及时", "主动",31 "认真", "用心", "可靠", "满分", "加分", "超值", "实惠",32 "划算", "合适", "可口", "好吃", "美味", "解渴", "凉快", "暖心",33 "惊艳", "治愈", "震憾", "耐看", "省力", "放心", "贴切", "灵活",34 "人性化", "有耐心", "有礼貌", "有温度", "有特色", "有文化", "有氛围",35 "有秩序", "有帮助", "有亮点", "有收获", "有诚意", "可接受", "可放心",36 "可推荐", "可再来", "适合拍照", "适合亲子", "适合休闲", "适合散步",37 "good", "great", "nice", "beautiful", "excellent", "perfect",38)39POSITIVE_SENTIMENT_PHRASES = _dedupe_terms(40 "值得推荐", "很推荐", "非常推荐", "强烈推荐", "值得来", "值得一来",41 "值得打卡", "值得二刷", "还想再来", "下次还来", "还会再来", "推荐朋友来",42 "带家人来", "想带朋友来", "值得专门来", "值得排队", "值得等待", "值得体验",43 "挺直观", "很直观", "导览直观", "地图直观", "标注醒目", "地图醒目",44 "入口好找", "出口好找", "位置好找", "找景点很快", "找景点方便",45 "挺有用", "很有用", "确实有用", "还挺有用", "确实能用", "功能能用",46 "至少能找到", "能找到大概位置", "还算清楚", "还算明白", "说明明白",47 "说明还算明白", "不复杂", "操作不复杂", "不卡", "切换不卡",48 "挺快", "反应挺快", "回复很快", "马上解决", "很实用", "提醒实用",49 "有重点", "内容有重点", "愿意听",50 "体验很好", "体验很棒", "体验很赞", "体验不错", "体验满意", "体验舒适",51 "体验愉快", "体验顺畅", "体验很顺", "体验轻松", "体验省心", "体验感好",52 "整体满意", "整体很满意", "整体不错", "整体很好", "整体很棒", "整体顺利",53 "整体舒服", "整体省心", "整体推荐", "总体满意", "总体不错", "总体很好",54 "玩得开心", "玩得舒服", "玩得尽兴", "玩得轻松", "玩得很值", "玩得很顺",55 "游览顺畅", "游玩顺利", "游玩轻松", "行程顺利", "行程合理", "行程舒服",56 "服务很好", "服务很棒", "服务很赞", "服务到位", "服务贴心", "服务周到",57 "服务热情", "服务专业", "服务细致", "服务耐心", "服务主动", "服务及时",58 "服务满分", "服务加分", "服务有温度", "态度很好", "态度不错", "态度亲切",59 "态度热情", "态度温和", "态度友好", "态度耐心", "态度礼貌", "态度诚恳",60 "工作人员热情", "工作人员负责", "工作人员耐心", "工作人员专业", "工作人员友好",61 "工作人员贴心", "工作人员主动", "工作人员细心", "工作人员靠谱", "工作人员有礼貌",62 "导游专业", "导游热情", "导游耐心", "导游负责", "导游讲得好", "导游很细心",63 "讲解很清楚", "讲解很详细", "讲解很生动", "讲解很专业", "讲解很有趣",64 "讲解有耐心", "讲解有文化", "讲解内容丰富", "讲得明白", "讲得清楚",65 "讲得详细", "讲得生动", "回答及时", "回答清楚", "回答准确", "有问必答",66 "帮助及时", "主动帮忙", "处理及时", "处理很快", "解决问题快", "指引清楚",67 "指引明确", "指引到位", "路线清楚", "路线合理", "路线顺畅", "路线好走",68 "路线安排合理", "路线推荐合理", "路标清晰", "路牌清楚", "标识清楚", "标识明确",69 "指示明确", "导航准确", "动线顺畅", "动线合理", "找路方便", "交通方便",70 "停车方便", "入口好找", "出口好找", "换乘方便", "观光车方便", "上下山方便",71 "入园顺利", "检票顺利", "预约方便", "购票方便", "退票方便", "验票很快",72 "票价合理", "价格合理", "性价比高", "值回票价", "门票值得", "优惠清楚",73 "排队很快", "等待不久", "排队可接受", "队伍有序", "秩序很好", "现场有序",74 "管理规范", "组织有序", "高峰也顺", "环境很好", "环境优美", "环境干净",75 "环境整洁", "环境舒服", "卫生干净", "卫生不错", "厕所干净", "洗手间干净",76 "地面干净", "空气清新", "味道清新", "景色很好", "风景很好", "景色漂亮",77 "风景漂亮", "景色震撼", "风景震撼", "景观壮观", "景观漂亮", "景区很美",78 "景区不错", "景区漂亮", "大佛震撼", "梵宫漂亮", "九龙灌浴精彩", "山顶风景好",79 "很好看", "很好玩", "很出片", "拍照好看", "拍照出片", "表演精彩",80 "演出精彩", "文化氛围好", "故事有意思", "内容丰富", "项目有趣", "好玩有趣",81 "适合拍照", "适合亲子", "适合老人", "适合慢慢逛", "餐饮不错", "小吃好吃",82 "饭菜可口", "饮品不错", "饮水方便", "补水方便", "休息方便", "座位充足",83 "设施完善", "设施齐全", "设施好用", "充电方便", "避雨方便", "防晒提醒贴心",84 "雨天也方便", "天气提醒及时", "安全感强", "很有安全感", "心情很好",85 "心情舒畅", "很有收获", "收获很多", "不虚此行", "超出预期", "超过预期",86 "超级满意", "非常满意", "特别满意", "真的不错", "真不错", "真棒",87 "真好", "太好了", "太赞了", "太喜欢了", "特别好", "非常好",88 "挺好的", "挺不错", "很省心", "特别省心", "方便省心", "没踩坑",89)90NEGATIVE_SENTIMENT_PHRASES = _dedupe_terms(91 "不喜欢", "不太喜欢", "不是很喜欢", "不怎么喜欢", "不满意", "不推荐",92 "不想再来", "不会再来", "再也不来", "不值得来", "不值票价", "不值这个价",93 "不好用", "一点都不好用", "一点不好用", "不方便", "不清楚", "不明白",94 "看不懂", "没看懂", "听不清", "讲不明白", "太敷衍", "介绍敷衍", "说得敷衍",95 "说得太普通", "太普通", "不知道景点有什么意思", "太机械", "很机械",96 "不自然", "很不自然", "像复制粘贴", "复制粘贴", "没什么重点", "没有重点",97 "讲解不清楚", "讲解不详细", "讲解不专业", "讲解没意思", "指引不清楚",98 "路线不清楚", "路线不合理", "路线太绕", "标识不清楚", "标识不明显",99 "路牌不明显", "指示不明确", "找不到路", "找不到入口", "找不到出口",100 "找不到厕所", "找不到洗手间", "找不到地方", "找半天找不到地方", "没有找到", "看不到指示", "没有指示",101 "导航不准", "路线绕远", "绕了好多路", "跟现场不一致", "路线不一致",102 "现场不一致", "发现封路", "动线混乱", "容易迷路", "走错路", "绕来绕去",103 "排队太久", "等太久", "等了很久", "等待太久", "队伍太长", "队伍很长",104 "入园太慢", "检票太慢", "处理太慢", "反应太慢", "响应太慢", "点了半天才出来",105 "老是卡住", "页面卡住", "点什么都没反应", "没反应", "人太多", "太拥挤", "特别拥挤",106 "挤得难受", "挤得不舒服", "现场混乱", "秩序不好", "管理混乱", "安排混乱",107 "服务差", "服务不好", "服务很差", "服务太差", "服务不到位", "服务不贴心",108 "服务不周到", "服务不专业", "服务不及时", "服务让人生气", "态度差",109 "态度不好", "态度很差", "态度太差", "态度冷淡", "态度敷衍", "态度生硬",110 "态度不耐烦", "工作人员不耐烦", "工作人员冷漠", "工作人员敷衍", "工作人员不负责",111 "工作人员态度差", "工作人员不专业", "导游不专业", "导游讲不清", "导游不耐烦",112 "回答不及时", "回答不清楚", "答非所问", "帮助不及时", "没人管", "无人处理",113 "体验差", "体验不好", "体验很差", "体验太差", "体验糟糕", "体验不舒服",114 "体验不顺畅", "体验很失望", "体验没意思", "感觉不好", "感觉有点麻烦", "心情不好", "很失望",115 "特别失望", "有点失望", "太失望", "令人失望", "比较失望", "没意思",116 "很无聊", "太无聊", "内容无聊", "表演无聊", "项目无聊", "景点无聊",117 "不好玩", "不好看", "拍照不好看", "景色失望", "风景失望", "景观一般",118 "有点乱", "信息太乱", "看着费劲", "看着很费劲", "用起来费劲", "用起来不舒服",119 "太累", "走得太累", "爬得太累", "太晒", "太热", "太冷",120 "闷得难受", "淋雨了", "路太滑", "地面太滑", "没地方躲雨", "没地方休息",121 "休息不方便", "座位不够", "座位太少", "设施不足", "设施老旧", "设施损坏",122 "设备故障", "充电不方便", "厕所脏", "洗手间脏", "卫生差", "环境脏",123 "环境不好", "环境很乱", "垃圾很多", "垃圾太多", "味道难闻", "异味很重",124 "地面很脏", "不干净", "餐饮差", "餐饮不好", "小吃难吃", "饮品难喝",125 "饭菜不好吃", "价格太高", "票价太高", "票价偏高", "票价不合理", "票价不值",126 "票太贵", "门票贵", "门票太贵", "门票偏贵", "门票价格高", "门票不值",127 "太贵", "收费太贵", "性价比低",128 "不划算", "不实惠", "感觉被坑", "有点坑人", "乱收费", "标价不清",129 "优惠不清楚", "预约麻烦", "购票麻烦", "退票麻烦", "入园麻烦", "流程繁琐",130 "信息不准", "信息错误", "信息太少", "景点信息太少", "信息过时", "开放时间不对",131 "优惠规则看不明白", "解释含糊", "说明含糊", "答不到点上", "回答很空",132 "回答空泛", "帮助不大", "没什么帮助", "帮不上忙", "基本帮不上忙",133 "休息点推荐太少", "推荐太少", "位置写错", "写错了", "白跑一趟",134 "整体粗糙", "感觉很粗糙", "功能鸡肋", "有点鸡肋", "有用的不多",135 "不如自己看地图", "解释不清", "讲解敷衍", "噪音太大", "太吵",136 "没有安全感", "安全隐患", "有安全隐患", "踩坑了", "坑了", "坑人",137 "糟心", "闹心", "气死了", "气死人", "太气人", "我生气了",138 "不要理你", "不想理你", "不理你", "讨厌你", "差评", "必须差评",139 "非常差", "特别差", "很差劲", "烂透了", "太离谱", "真离谱",140 "真无语", "很崩溃", "受不了", "令人不适", "不舒服", "又累又烦",141 "白来了", "后悔来了", "浪费时间", "浪费钱", "影响心情", "没有达到预期",142 "没达到预期", "一点也不好", "路线推荐不太行", "不太行",143)144NEGATIVE_SENTIMENT_KEYWORDS = _dedupe_terms(145 "拥挤", "脏乱", "迷路", "投诉", "失望", "冷淡", "敷衍", "生硬",146 "不耐烦", "冷漠", "敷衍", "模糊", "机械", "生硬", "糟糕", "无聊",147 "难受", "费劲", "不适", "糟心", "闹心",148 "崩溃", "离谱", "无语", "坑人", "宰客", "乱收费", "难找", "绕远",149 "混乱", "排队久", "队伍长", "等待久", "人太多", "不清晰", "不明确",150 "不专业", "不负责", "不及时", "不到位", "不贴心", "不周到", "不合理",151 "不顺畅", "不方便", "不干净", "异味", "难闻", "垃圾多", "设施旧",152 "设施坏", "故障", "损坏", "太贵", "票贵", "票价贵", "门票贵",153 "昂贵", "价格高", "票价高", "门票高", "性价比低",154 "不划算", "不实惠", "麻烦", "繁琐", "拥堵", "太吵", "噪音大",155 "太晒", "闷热", "太冷", "淋雨", "路滑", "危险", "隐患", "担心",156 "焦虑", "生气", "气愤", "恼火", "讨厌", "厌烦", "反感", "差劲",157 "卡顿", "卡住", "页面卡", "有点卡", "没反应", "过时", "不一致",158 "封路", "写错", "粗糙", "鸡肋", "太普通",159 "烂透", "难吃", "难喝", "不好吃", "不好喝", "口味差", "没座位",160 "座位少", "不省心", "不安心", "不舒服", "没意思", "看不懂",161 "听不清", "不好玩", "不好看", "不满意", "不推荐", "不值当",162 "不值票", "不友好", "不礼貌", "不靠谱", "不准确", "不安全",163 "bad", "expensive", "crowded", "dirty", "awful", "terrible",164)165ABUSIVE_SENTIMENT_KEYWORDS = _dedupe_terms(166 "sb", "傻逼", "垃圾服务", "垃圾态度", "垃圾体验", "笨蛋", "蠢货",167 "闭嘴", "废物", "有病", "神经病", "脑子有病", "滚开", "别烦我",168 "烦死了", "气死我了", "讨厌死了", "恶心人", "真恶心", "太恶心",169 "烂服务", "破服务", "破地方", "破景区", "什么玩意", "一塌糊涂",170)171SENTIMENT_NEGATORS = ("不", "没", "没有", "无", "别", "不要", "不是", "不太", "不怎么")172DEGREE_WEIGHTS = {173 "非常": 1.6,174 "特别": 1.6,175 "太": 1.5,176 "很": 1.3,177 "比较": 1.15,178 "有点": 0.8,179 "稍微": 0.7,180}181CONTRAST_MARKERS = ("但是", "但", "不过", "可是", "然而", "就是", "倒是")182NEUTRAL_FEEDBACK_PHRASES = ("一般", "还行", "还可以", "普通", "凑合", "还好")183NON_FEEDBACK_EXACT = {184 "你好", "您好", "hello", "hi", "嗨", "在吗", "谢谢", "再见",185}186NON_FEEDBACK_HINTS = (187 "吗", "呢", "么", "多少", "几", "哪里", "在哪", "有没有", "有什么",188 "怎么", "如何", "能不能", "可以", "请", "帮我", "给我", "推荐",189 "路线", "门票", "票价", "价格", "多少钱", "厕所", "洗手间", "小吃",190 "餐厅", "名人", "来过", "你是", "男的", "女的", "个人资料", "介绍一下",191)192QUESTION_HINTS = (193 "吗", "呢", "么", "多少", "哪里", "在哪", "有没有", "有什么",194 "怎么", "如何", "能不能", "请问", "?",195)196DIRECT_QUESTION_ENDINGS = ("吗", "呢", "么", "?", "?")197FEEDBACK_TARGET_HINTS = (198 "景区", "景点", "服务", "工作人员", "导游", "讲解", "路线", "门票",199 "票价", "排队", "厕所", "洗手间", "餐饮", "小吃", "环境", "风景",200 "景色", "体验", "梵宫", "大佛", "九龙灌浴", "山顶", "导览", "地图",201 "页面", "功能", "操作", "介绍", "内容", "ai导游", "ai 导游", "语音",202 "反应", "响应", "信息", "标注", "提醒", "回复", "开放时间", "位置",203 "优惠", "说明", "智能导览",204)205SENTIMENT_TOPICS = {206 "服务接待": ("服务", "工作人员", "导游", "态度", "讲解", "客服", "帮助", "问询", "回复"),207 "交通路线": ("路线", "怎么走", "迷路", "路标", "指示", "导航", "地图", "导览", "找景点", "观光车", "停车", "入口", "出口"),208 "门票排队": ("门票", "票", "排队", "入园", "预约", "价格", "贵", "优惠"),209 "环境卫生": ("卫生", "厕所", "洗手间", "垃圾", "脏", "干净", "环境", "味道"),210 "餐饮休息": ("吃", "餐饮", "饭", "咖啡", "水", "休息", "座位", "小吃"),211 "景点体验": ("景点", "表演", "拍照", "大佛", "梵宫", "九龙灌浴", "风景", "景色", "好玩", "无聊", "介绍", "内容", "页面", "功能", "操作", "语音", "反应", "响应", "信息", "标注", "提醒", "开放时间", "说明", "智能导览"),212 "天气舒适": ("天气", "热", "冷", "下雨", "晒", "雨", "风", "防晒"),213}214TOPIC_SUGGESTIONS = {215 "服务接待": "加强入口、问询点和重点景点的服务话术培训,优先处理游客提到的态度、讲解不清或帮助不及时问题。",216 "交通路线": "补强路线指引、路牌和地图热点说明,在热门岔路口增加更明确的下一站提示。",217 "门票排队": "关注票价解释、预约流程和高峰排队提示,提前展示预计等待时间和优惠政策。",218 "环境卫生": "把负面集中区域纳入巡检清单,增加卫生间、垃圾桶和休息区的高峰巡检频次。",219 "餐饮休息": "优化餐饮、饮水和休息点的导览说明,在游客疲劳节点主动推荐附近休息设施。",220 "景点体验": "复盘被频繁吐槽的景点讲解内容和动线安排,增强拍照点、演出时间和文化故事说明。",221 "天气舒适": "根据天气主动推送防晒、避雨、防滑和补水提醒,并推荐更舒适的室内外路线组合。",222 "综合体验": "继续收集游客原话,优先复盘高频负面问题,再把处理结果补进知识库和现场服务流程。",223}224 225 226def _connect() -> sqlite3.Connection:227 conn = sqlite3.connect(DB_PATH)228 conn.row_factory = sqlite3.Row229 return conn230 231 232def _init_db_sync() -> None:233 DATA_DIR.mkdir(parents=True, exist_ok=True)234 with _connect() as conn:235 conn.execute(236 """237 CREATE TABLE IF NOT EXISTS user_queries (238 id INTEGER PRIMARY KEY AUTOINCREMENT,239 user_id TEXT NOT NULL,240 question TEXT NOT NULL,241 answer TEXT NOT NULL,242 mode TEXT NOT NULL DEFAULT 'rag',243 sentiment TEXT NOT NULL DEFAULT 'neutral',244 sentiment_score REAL NOT NULL DEFAULT 0,245 sentiment_reason TEXT NOT NULL DEFAULT '常规咨询',246 sentiment_topic TEXT NOT NULL DEFAULT '综合体验',247 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP248 )249 """250 )251 columns = {252 row["name"]253 for row in conn.execute("PRAGMA table_info(user_queries)").fetchall()254 }255 if "mode" not in columns:256 conn.execute(257 "ALTER TABLE user_queries ADD COLUMN mode TEXT NOT NULL DEFAULT 'rag'"258 )259 sentiment_columns = {260 "sentiment": "TEXT NOT NULL DEFAULT 'neutral'",261 "sentiment_score": "REAL NOT NULL DEFAULT 0",262 "sentiment_reason": "TEXT NOT NULL DEFAULT '常规咨询'",263 "sentiment_topic": "TEXT NOT NULL DEFAULT '综合体验'",264 }265 for column, definition in sentiment_columns.items():266 if column not in columns:267 conn.execute(f"ALTER TABLE user_queries ADD COLUMN {column} {definition}")268 conn.execute(269 """270 CREATE TABLE IF NOT EXISTS guide_settings (271 id INTEGER PRIMARY KEY CHECK (id = 1),272 avatar_id TEXT NOT NULL,273 voice_id TEXT NOT NULL,274 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP275 )276 """277 )278 conn.execute(279 """280 CREATE TABLE IF NOT EXISTS users (281 id TEXT PRIMARY KEY,282 username TEXT NOT NULL UNIQUE,283 password_hash TEXT NOT NULL,284 password_salt TEXT NOT NULL,285 nickname TEXT NOT NULL,286 avatar TEXT NOT NULL DEFAULT '',287 bio TEXT NOT NULL DEFAULT '',288 interests TEXT NOT NULL DEFAULT '',289 traveler_type TEXT NOT NULL DEFAULT '',290 personality TEXT NOT NULL DEFAULT 'friendly',291 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,292 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP293 )294 """295 )296 user_columns = {297 row["name"]298 for row in conn.execute("PRAGMA table_info(users)").fetchall()299 }300 if "personality" not in user_columns:301 conn.execute(302 "ALTER TABLE users ADD COLUMN personality TEXT NOT NULL DEFAULT 'friendly'"303 )304 conn.execute(305 """306 CREATE TABLE IF NOT EXISTS knowledge_gaps (307 id INTEGER PRIMARY KEY AUTOINCREMENT,308 question TEXT NOT NULL UNIQUE,309 mode TEXT NOT NULL DEFAULT 'rag',310 count INTEGER NOT NULL DEFAULT 1,311 status TEXT NOT NULL DEFAULT 'pending',312 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,313 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP314 )315 """316 )317 conn.execute(318 """319 CREATE TABLE IF NOT EXISTS manual_knowledge (320 id INTEGER PRIMARY KEY AUTOINCREMENT,321 question TEXT NOT NULL,322 answer TEXT NOT NULL,323 category TEXT NOT NULL DEFAULT 'general',324 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP325 )326 """327 )328 conn.commit()329 330 331async def init_db() -> None:332 await asyncio.to_thread(_init_db_sync)333 334 335def _text_contains_any(text: str, words: tuple[str, ...] | set[str]) -> bool:336 return any(word.lower() in text for word in words)337 338 339def _weighted_hit(text: str, start: int) -> float:340 prefix = text[max(0, start - 4):start]341 for word, weight in DEGREE_WEIGHTS.items():342 if word in prefix:343 return weight344 return 1.0345 346 347def _prefix_has_active_negator(prefix: str) -> bool:348 compact = "".join(prefix.split())349 return any(compact.endswith(negator) for negator in SENTIMENT_NEGATORS)350 351 352def _split_for_contrast(text: str) -> list[tuple[str, float]]:353 for marker in CONTRAST_MARKERS:354 if marker in text:355 before, after = text.split(marker, 1)356 return [(before, 0.65), (after, 1.35)]357 return [(text, 1.0)]358 359 360def _match_weighted_terms(361 text: str,362 terms: tuple[str, ...],363 base_polarity: int,364) -> tuple[float, list[str]]:365 score = 0.0366 hits = []367 for segment, segment_weight in _split_for_contrast(text):368 for term in terms:369 keyword = term.lower()370 start = segment.find(keyword)371 if start < 0:372 continue373 374 prefix = segment[max(0, start - 5):start]375 polarity = base_polarity376 hit_label = term377 if _prefix_has_active_negator(prefix):378 polarity *= -1379 hit_label = f"{prefix}{term}".strip()380 381 score += polarity * _weighted_hit(segment, start) * segment_weight382 if hit_label not in hits:383 hits.append(hit_label)384 return score, hits385 386 387def analyze_visitor_sentiment(question: str, answer: str = "") -> dict:388 text = (question or answer or "").strip().lower()389 compact_text = "".join(text.split())390 if not compact_text:391 return {392 "sentiment": "not_feedback",393 "sentiment_score": 0,394 "sentiment_reason": "空文本未形成评价",395 "sentiment_topic": "非评价",396 "keywords": [],397 }398 399 topic_scores = {400 topic: sum(1 for word in keywords if word.lower() in text)401 for topic, keywords in SENTIMENT_TOPICS.items()402 }403 topic = max(topic_scores, key=topic_scores.get, default="综合体验")404 if topic_scores.get(topic, 0) == 0:405 topic = "综合体验"406 407 score = 0.0408 all_hits = []409 for terms, polarity in (410 (POSITIVE_SENTIMENT_PHRASES, 1),411 (NEGATIVE_SENTIMENT_PHRASES, -1),412 (ABUSIVE_SENTIMENT_KEYWORDS, -1),413 (POSITIVE_SENTIMENT_KEYWORDS, 1),414 (NEGATIVE_SENTIMENT_KEYWORDS, -1),415 ):416 term_score, hits = _match_weighted_terms(text, terms, polarity)417 score += term_score418 all_hits.extend(hit for hit in hits if hit not in all_hits)419 420 has_abusive_or_anger_signal = _text_contains_any(421 text,422 ABUSIVE_SENTIMENT_KEYWORDS + ("生气", "气死", "气人", "不要理你", "不理你", "讨厌你"),423 )424 if has_abusive_or_anger_signal:425 topic = "服务接待"426 427 has_neutral_feedback = _text_contains_any(text, NEUTRAL_FEEDBACK_PHRASES)428 has_feedback_target = _text_contains_any(text, FEEDBACK_TARGET_HINTS)429 has_feedback_signal = bool(all_hits) or has_neutral_feedback430 has_explicit_feedback_phrase = _text_contains_any(431 text,432 POSITIVE_SENTIMENT_PHRASES + NEGATIVE_SENTIMENT_PHRASES + NEUTRAL_FEEDBACK_PHRASES,433 )434 is_question_like = _text_contains_any(text, QUESTION_HINTS)435 is_direct_question = compact_text.endswith(DIRECT_QUESTION_ENDINGS)436 is_plain_non_feedback = (437 compact_text in NON_FEEDBACK_EXACT438 or is_direct_question439 or (is_question_like and not has_explicit_feedback_phrase)440 or (441 not has_feedback_signal442 and _text_contains_any(text, NON_FEEDBACK_HINTS)443 )444 )445 if is_plain_non_feedback or not has_feedback_signal:446 return {447 "sentiment": "not_feedback",448 "sentiment_score": 0,449 "sentiment_reason": "普通咨询或闲聊,不计入景区情感评价",450 "sentiment_topic": "非评价",451 "keywords": [],452 }453 454 normalized_score = max(-1.0, min(1.0, score / 3))455 topic_label = topic if topic.endswith("体验") else f"{topic}体验"456 if normalized_score > 0.18:457 sentiment = "positive"458 reason = f"对{topic_label}认可"459 elif normalized_score < -0.18:460 sentiment = "negative"461 reason = "服务互动中出现负面情绪" if has_abusive_or_anger_signal else f"{topic_label}待改进"462 elif has_neutral_feedback and has_feedback_target:463 sentiment = "neutral"464 reason = f"{topic_label}反馈一般"465 else:466 sentiment = "neutral"467 reason = f"{topic_label}反馈不明显"468 469 return {470 "sentiment": sentiment,471 "sentiment_score": round(normalized_score, 2),472 "sentiment_reason": reason,473 "sentiment_topic": topic,474 "keywords": all_hits,475 }476 477 478def _sentiment_from_row(row: sqlite3.Row) -> dict:479 return analyze_visitor_sentiment(row["question"], row["answer"])480 481 482def _build_sentiment_suggestions(483 sentiment_counts: Counter,484 topic_counts: Counter,485 negative_topic_counts: Counter,486 total: int,487) -> list[dict]:488 if total <= 0:489 return [{490 "priority": "观察",491 "topic": "暂无数据",492 "issue": "游客对景区及服务的情感样本还不够",493 "suggestion": "上线后持续收集游客提问和反馈,建议先观察 1-3 天再做运营调整。",494 "count": 0,495 }]496 497 suggestions = []498 for topic, count in negative_topic_counts.most_common(5):499 ratio = count / total500 suggestions.append({501 "priority": "高" if ratio >= 0.2 or count >= 5 else "中",502 "topic": topic,503 "issue": f"{topic}相关负面反馈 {count} 次,占全部互动 {ratio:.0%}",504 "suggestion": TOPIC_SUGGESTIONS.get(topic, TOPIC_SUGGESTIONS["综合体验"]),505 "count": count,506 })507 508 positive_ratio = sentiment_counts["positive"] / total509 negative_ratio = sentiment_counts["negative"] / total510 if not suggestions and positive_ratio >= 0.5:511 top_topic = topic_counts.most_common(1)[0][0] if topic_counts else "综合体验"512 suggestions.append({513 "priority": "保持",514 "topic": top_topic,515 "issue": f"正向反馈占比 {positive_ratio:.0%},整体体验较稳定",516 "suggestion": f"继续保持{top_topic}优势,把游客认可的讲解话术、路线推荐和服务动作沉淀为标准流程。",517 "count": sentiment_counts["positive"],518 })519 elif negative_ratio == 0:520 suggestions.append({521 "priority": "观察",522 "topic": "综合体验",523 "issue": "暂未出现明显负面情绪",524 "suggestion": "继续扩大样本量,重点关注高峰期、雨天和热门景点的反馈变化。",525 "count": 0,526 })527 528 return suggestions529 530 531def _add_user_query_sync(532 user_id: str,533 question: str,534 answer: str,535 mode: str = "rag",536) -> None:537 DATA_DIR.mkdir(parents=True, exist_ok=True)538 sentiment = analyze_visitor_sentiment(question, answer)539 with _connect() as conn:540 conn.execute(541 """542 INSERT INTO user_queries (543 user_id, question, answer, mode,544 sentiment, sentiment_score, sentiment_reason, sentiment_topic545 )546 VALUES (?, ?, ?, ?, ?, ?, ?, ?)547 """,548 (549 user_id,550 question,551 answer,552 mode,553 sentiment["sentiment"],554 sentiment["sentiment_score"],555 sentiment["sentiment_reason"],556 sentiment["sentiment_topic"],557 ),558 )559 conn.commit()560 561 562async def add_user_query(563 user_id: str,564 question: str,565 answer: str,566 mode: str = "rag",567) -> None:568 await asyncio.to_thread(_add_user_query_sync, user_id, question, answer, mode)569 570 571def _fetch_user_queries_sync(user_id: str, limit: int) -> list[dict]:572 DATA_DIR.mkdir(parents=True, exist_ok=True)573 with _connect() as conn:574 rows = conn.execute(575 """576 SELECT id, user_id, question, answer, mode,577 sentiment, sentiment_score, sentiment_reason, sentiment_topic,578 created_at579 FROM user_queries580 WHERE user_id = ?581 ORDER BY id DESC582 LIMIT ?583 """,584 (user_id, limit),585 ).fetchall()586 587 return [dict(row) for row in rows]588 589 590async def fetch_user_queries(user_id: str, limit: int = 20) -> list[dict]:591 return await asyncio.to_thread(_fetch_user_queries_sync, user_id, limit)592 593 594def _format_query_row(row: sqlite3.Row) -> dict:595 sentiment = _sentiment_from_row(row)596 return {597 "id": row["id"],598 "user_id": row["user_id"],599 "question": row["question"],600 "answer": row["answer"],601 "mode": row["mode"],602 **sentiment,603 "timestamp": row["created_at"],604 "created_at": row["created_at"],605 }606 607 608def _get_admin_stats_sync() -> dict:609 DATA_DIR.mkdir(parents=True, exist_ok=True)610 with _connect() as conn:611 today_users = conn.execute(612 """613 SELECT COUNT(DISTINCT user_id)614 FROM user_queries615 WHERE date(created_at) = date('now', 'localtime')616 """617 ).fetchone()[0]618 today_questions = conn.execute(619 """620 SELECT COUNT(*)621 FROM user_queries622 WHERE date(created_at) = date('now', 'localtime')623 """624 ).fetchone()[0]625 total_queries = conn.execute("SELECT COUNT(*) FROM user_queries").fetchone()[0]626 total_users = conn.execute(627 "SELECT COUNT(DISTINCT user_id) FROM user_queries"628 ).fetchone()[0]629 top_questions = [630 {"question": row["question"], "count": row["count"]}631 for row in conn.execute(632 """633 SELECT question, COUNT(*) AS count634 FROM user_queries635 GROUP BY question636 ORDER BY count DESC, MAX(created_at) DESC637 LIMIT 10638 """639 ).fetchall()640 ]641 mode_stats = [642 {"mode": row["mode"], "count": row["count"]}643 for row in conn.execute(644 """645 SELECT mode, COUNT(*) AS count646 FROM user_queries647 GROUP BY mode648 ORDER BY count DESC649 """650 ).fetchall()651 ]652 recent_visits = [653 _format_query_row(row)654 for row in conn.execute(655 """656 SELECT id, user_id, question, answer, mode,657 sentiment, sentiment_score, sentiment_reason, sentiment_topic,658 created_at659 FROM user_queries660 ORDER BY id DESC661 LIMIT 20662 """663 ).fetchall()664 ]665 sentiment_rows = conn.execute(666 """667 SELECT id, question, answer, sentiment, sentiment_score,668 sentiment_reason, sentiment_topic, created_at669 FROM user_queries670 ORDER BY id DESC671 LIMIT 500672 """673 ).fetchall()674 675 sentiment_counts = Counter()676 topic_counts = Counter()677 reason_counts = Counter()678 negative_topic_counts = Counter()679 not_feedback_count = 0680 recent_sentiment = []681 682 for row in sentiment_rows:683 payload = _sentiment_from_row(row)684 sentiment = payload["sentiment"]685 topic = payload["sentiment_topic"]686 reason = payload["sentiment_reason"]687 score = float(payload["sentiment_score"] or 0)688 if sentiment == "not_feedback":689 not_feedback_count += 1690 sentiment_counts["neutral"] += 1691 else:692 sentiment_counts[sentiment] += 1693 topic_counts[topic] += 1694 reason_counts[reason] += 1695 if sentiment == "negative":696 negative_topic_counts[topic] += 1697 698 if len(recent_sentiment) < 12:699 recent_sentiment.append({700 "id": row["id"],701 "question": row["question"],702 "sentiment": sentiment,703 "score": score,704 "topic": topic,705 "reason": reason,706 "timestamp": row["created_at"],707 })708 709 sentiment_total = sum(sentiment_counts.values())710 feedback_sentiment_total = sentiment_total - not_feedback_count711 712 return {713 "today_users": today_users,714 "today_questions": today_questions,715 "total_queries": total_queries,716 "total_users": total_users,717 "top_questions": top_questions,718 "mode_stats": mode_stats,719 "recent_visits": recent_visits,720 "sentiment_summary": [721 {"sentiment": key, "count": sentiment_counts[key]}722 for key in ("positive", "neutral", "negative")723 ],724 "not_feedback_count": not_feedback_count,725 "sentiment_topic_stats": [726 {"topic": topic, "count": count}727 for topic, count in topic_counts.most_common(8)728 ],729 "sentiment_reason_stats": [730 {"reason": reason, "count": count}731 for reason, count in reason_counts.most_common(8)732 ],733 "recent_sentiment": recent_sentiment,734 "sentiment_suggestions": _build_sentiment_suggestions(735 sentiment_counts,736 topic_counts,737 negative_topic_counts,738 feedback_sentiment_total,739 ),740 }741 742 743async def get_admin_stats() -> dict:744 return await asyncio.to_thread(_get_admin_stats_sync)745 746 747def _get_all_users_with_stats_sync() -> list[dict]:748 DATA_DIR.mkdir(parents=True, exist_ok=True)749 with _connect() as conn:750 rows = conn.execute(751 """752 SELECT753 q.user_id,754 u.username,755 u.nickname,756 COUNT(*) AS total_queries,757 MAX(q.created_at) AS last_active758 FROM user_queries AS q759 LEFT JOIN users AS u ON u.id = q.user_id760 GROUP BY q.user_id, u.username, u.nickname761 ORDER BY total_queries DESC, last_active DESC762 """763 ).fetchall()764 765 return [766 {767 "user_id": row["user_id"],768 "username": row["username"] or "",769 "nickname": row["nickname"] or "",770 "total_queries": row["total_queries"],771 "last_active": row["last_active"],772 }773 for row in rows774 ]775 776 777async def get_all_users_with_stats() -> list[dict]:778 return await asyncio.to_thread(_get_all_users_with_stats_sync)779 780 781def _get_user_detail_sync(user_id: str, limit: int = 50) -> list[dict]:782 DATA_DIR.mkdir(parents=True, exist_ok=True)783 with _connect() as conn:784 rows = conn.execute(785 """786 SELECT id, user_id, question, answer, mode,787 sentiment, sentiment_score, sentiment_reason, sentiment_topic,788 created_at789 FROM user_queries790 WHERE user_id = ?791 ORDER BY id DESC792 LIMIT ?793 """,794 (user_id, limit),795 ).fetchall()796 797 return [_format_query_row(row) for row in rows]798 799 800async def get_user_detail(user_id: str, limit: int = 50) -> list[dict]:801 return await asyncio.to_thread(_get_user_detail_sync, user_id, limit)802 803 804def _delete_query_sync(query_id: int) -> bool:805 DATA_DIR.mkdir(parents=True, exist_ok=True)806 with _connect() as conn:807 cursor = conn.execute("DELETE FROM user_queries WHERE id = ?", (query_id,))808 conn.commit()809 return cursor.rowcount > 0810 811 812async def delete_query(query_id: int) -> bool:813 return await asyncio.to_thread(_delete_query_sync, query_id)814 815 816def _search_queries_sync(keyword: str, limit: int = 50) -> list[dict]:817 DATA_DIR.mkdir(parents=True, exist_ok=True)818 pattern = f"%{keyword}%"819 with _connect() as conn:820 rows = conn.execute(821 """822 SELECT id, user_id, question, answer, mode,823 sentiment, sentiment_score, sentiment_reason, sentiment_topic,824 created_at825 FROM user_queries826 WHERE question LIKE ? OR answer LIKE ?827 ORDER BY id DESC828 LIMIT ?829 """,830 (pattern, pattern, limit),831 ).fetchall()832 833 return [_format_query_row(row) for row in rows]834 835 836async def search_queries(keyword: str, limit: int = 50) -> list[dict]:837 return await asyncio.to_thread(_search_queries_sync, keyword, limit)838 839 840def _log_knowledge_gap_sync(question: str, mode: str = "rag") -> None:841 DATA_DIR.mkdir(parents=True, exist_ok=True)842 with _connect() as conn:843 row = conn.execute(844 "SELECT id FROM knowledge_gaps WHERE question = ?",845 (question,),846 ).fetchone()847 if row:848 conn.execute(849 """850 UPDATE knowledge_gaps851 SET count = count + 1,852 mode = ?,853 status = CASE WHEN status = 'dismissed' THEN 'dismissed' ELSE 'pending' END,854 updated_at = CURRENT_TIMESTAMP855 WHERE id = ?856 """,857 (mode, row["id"]),858 )859 else:860 conn.execute(861 """862 INSERT INTO knowledge_gaps (question, mode, count, status)863 VALUES (?, ?, 1, 'pending')864 """,865 (question, mode),866 )867 conn.commit()868 869 870async def log_knowledge_gap(question: str, mode: str = "rag") -> None:871 await asyncio.to_thread(_log_knowledge_gap_sync, question, mode)872 873 874def _get_knowledge_gaps_sync(status: str = "pending", limit: int = 50) -> list[dict]:875 DATA_DIR.mkdir(parents=True, exist_ok=True)876 with _connect() as conn:877 rows = conn.execute(878 """879 SELECT id, question, mode, count, status, created_at, updated_at880 FROM knowledge_gaps881 WHERE status = ?882 ORDER BY count DESC, id DESC883 LIMIT ?884 """,885 (status, limit),886 ).fetchall()887 888 return [dict(row) for row in rows]889 890 891async def get_knowledge_gaps(status: str = "pending", limit: int = 50) -> list[dict]:892 return await asyncio.to_thread(_get_knowledge_gaps_sync, status, limit)893 894 895def _add_manual_knowledge_sync(896 question: str,897 answer: str,898 category: str = "general",899) -> int:900 DATA_DIR.mkdir(parents=True, exist_ok=True)901 with _connect() as conn:902 cursor = conn.execute(903 """904 INSERT INTO manual_knowledge (question, answer, category)905 VALUES (?, ?, ?)906 """,907 (question, answer, category),908 )909 conn.commit()910 return int(cursor.lastrowid)911 912 913async def add_manual_knowledge(914 question: str,915 answer: str,916 category: str = "general",917) -> int:918 return await asyncio.to_thread(919 _add_manual_knowledge_sync,920 question,921 answer,922 category,923 )924 925 926def _resolve_knowledge_gap_sync(gap_id: int, answer: str) -> bool:927 DATA_DIR.mkdir(parents=True, exist_ok=True)928 with _connect() as conn:929 gap = conn.execute(930 "SELECT question, mode FROM knowledge_gaps WHERE id = ?",931 (gap_id,),932 ).fetchone()933 if not gap:934 return False935 936 conn.execute(937 """938 INSERT INTO manual_knowledge (question, answer, category)939 VALUES (?, ?, ?)940 """,941 (gap["question"], answer, gap["mode"]),942 )943 conn.execute(944 """945 UPDATE knowledge_gaps946 SET status = 'resolved',947 updated_at = CURRENT_TIMESTAMP948 WHERE id = ?949 """,950 (gap_id,),951 )952 conn.commit()953 return True954 955 956async def resolve_knowledge_gap(gap_id: int, answer: str) -> bool:957 return await asyncio.to_thread(_resolve_knowledge_gap_sync, gap_id, answer)958 959 960def _dismiss_knowledge_gap_sync(gap_id: int) -> bool:961 DATA_DIR.mkdir(parents=True, exist_ok=True)962 with _connect() as conn:963 cursor = conn.execute(964 """965 UPDATE knowledge_gaps966 SET status = 'dismissed',967 updated_at = CURRENT_TIMESTAMP968 WHERE id = ?969 """,970 (gap_id,),971 )972 conn.commit()973 return cursor.rowcount > 0974 975 976async def dismiss_knowledge_gap(gap_id: int) -> bool:977 return await asyncio.to_thread(_dismiss_knowledge_gap_sync, gap_id)978 979 980def _get_manual_knowledge_sync(limit: int = 50) -> list[dict]:981 DATA_DIR.mkdir(parents=True, exist_ok=True)982 with _connect() as conn:983 rows = conn.execute(984 """985 SELECT id, question, answer, category, created_at986 FROM manual_knowledge987 ORDER BY id DESC988 LIMIT ?989 """,990 (limit,),991 ).fetchall()992 993 return [dict(row) for row in rows]994 995 996async def get_manual_knowledge(limit: int = 50) -> list[dict]:997 return await asyncio.to_thread(_get_manual_knowledge_sync, limit)998 999 1000def _delete_manual_knowledge_sync(entry_id: int) -> bool:1001 DATA_DIR.mkdir(parents=True, exist_ok=True)1002 with _connect() as conn:1003 cursor = conn.execute("DELETE FROM manual_knowledge WHERE id = ?", (entry_id,))1004 conn.commit()1005 return cursor.rowcount > 01006 1007 1008async def delete_manual_knowledge(entry_id: int) -> bool:1009 return await asyncio.to_thread(_delete_manual_knowledge_sync, entry_id)1010 1011 1012def _search_manual_knowledge_sync(question: str, top_k: int = 3) -> list[dict]:1013 DATA_DIR.mkdir(parents=True, exist_ok=True)1014 q_clean = question.strip()1015 if not q_clean:1016 return []1017 1018 ngrams = set()1019 for n in (2, 3):1020 for index in range(max(0, len(q_clean) - n + 1)):1021 ngrams.add(q_clean[index:index + n])1022 1023 with _connect() as conn:1024 rows = conn.execute(1025 """1026 SELECT id, question, answer, category, created_at1027 FROM manual_knowledge1028 ORDER BY id DESC1029 LIMIT 1001030 """1031 ).fetchall()1032 1033 matches = []1034 for row in rows:1035 score = 01036 row_question = row["question"]1037 for ngram in ngrams:1038 if ngram and ngram in row_question:1039 score += 11040 if q_clean in row_question or row_question in q_clean:1041 score += 51042 if score > 0:1043 payload = dict(row)1044 payload["score"] = score1045 matches.append(payload)1046 1047 matches.sort(key=lambda item: item["score"], reverse=True)1048 return matches[:top_k]1049 1050 1051async def search_manual_knowledge(question: str, top_k: int = 3) -> list[dict]:1052 return await asyncio.to_thread(_search_manual_knowledge_sync, question, top_k)1053 1054 1055def _hash_password(password: str, salt: str | None = None) -> tuple[str, str]:1056 password_salt = salt or os.urandom(16).hex()1057 password_hash = hashlib.pbkdf2_hmac(1058 "sha256",1059 password.encode("utf-8"),1060 bytes.fromhex(password_salt),1061 PASSWORD_ITERATIONS,1062 ).hex()1063 return password_hash, password_salt1064 1065 1066def _verify_password(password: str, password_hash: str, password_salt: str) -> bool:1067 candidate_hash, _ = _hash_password(password, password_salt)1068 return hmac.compare_digest(candidate_hash, password_hash)1069 1070 1071def _format_user_profile(row: sqlite3.Row) -> dict[str, str]:1072 return {1073 "userId": row["id"],1074 "username": row["username"],1075 "nickname": row["nickname"],1076 "avatar": row["avatar"],1077 "bio": row["bio"],1078 "interests": row["interests"],1079 "travelerType": row["traveler_type"],1080 "personality": row["personality"] or "friendly",1081 }1082 1083 1084def _create_user_sync(1085 username: str,1086 password: str,1087 nickname: str,1088 avatar: str = "",1089 bio: str = "",1090 interests: str = "",1091 traveler_type: str = "",1092 personality: str = "friendly",1093) -> dict[str, str]:1094 DATA_DIR.mkdir(parents=True, exist_ok=True)1095 user_id = uuid.uuid4().hex1096 password_hash, password_salt = _hash_password(password)1097 1098 with _connect() as conn:1099 try:1100 conn.execute(1101 """1102 INSERT INTO users (1103 id, username, password_hash, password_salt, nickname,1104 avatar, bio, interests, traveler_type, personality1105 )1106 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)1107 """,1108 (1109 user_id,1110 username,1111 password_hash,1112 password_salt,1113 nickname,1114 avatar,1115 bio,1116 interests,1117 traveler_type,1118 personality,1119 ),1120 )1121 conn.commit()1122 except sqlite3.IntegrityError as exc:1123 raise ValueError("username_taken") from exc1124 1125 row = conn.execute(1126 """1127 SELECT id, username, nickname, avatar, bio, interests, traveler_type, personality1128 FROM users1129 WHERE id = ?1130 """,1131 (user_id,),1132 ).fetchone()1133 1134 return _format_user_profile(row)1135 1136 1137async def create_user(1138 username: str,1139 password: str,1140 nickname: str,1141 avatar: str = "",1142 bio: str = "",1143 interests: str = "",1144 traveler_type: str = "",1145 personality: str = "friendly",1146) -> dict[str, str]:1147 return await asyncio.to_thread(1148 _create_user_sync,1149 username,1150 password,1151 nickname,1152 avatar,1153 bio,1154 interests,1155 traveler_type,1156 personality,1157 )1158 1159 1160def _authenticate_user_sync(username: str, password: str) -> dict[str, str] | None:1161 DATA_DIR.mkdir(parents=True, exist_ok=True)1162 with _connect() as conn:1163 row = conn.execute(1164 """1165 SELECT id, username, password_hash, password_salt, nickname,1166 avatar, bio, interests, traveler_type, personality1167 FROM users1168 WHERE username = ?1169 """,1170 (username,),1171 ).fetchone()1172 1173 if not row or not _verify_password(password, row["password_hash"], row["password_salt"]):1174 return None1175 1176 return _format_user_profile(row)1177 1178 1179async def authenticate_user(username: str, password: str) -> dict[str, str] | None:1180 return await asyncio.to_thread(_authenticate_user_sync, username, password)1181 1182 1183def _fetch_user_profile_sync(user_id: str) -> dict[str, str] | None:1184 DATA_DIR.mkdir(parents=True, exist_ok=True)1185 with _connect() as conn:1186 row = conn.execute(1187 """1188 SELECT id, username, nickname, avatar, bio, interests, traveler_type, personality1189 FROM users1190 WHERE id = ?1191 """,1192 (user_id,),1193 ).fetchone()1194 1195 return _format_user_profile(row) if row else None1196 1197 1198async def fetch_user_profile(user_id: str) -> dict[str, str] | None:1199 return await asyncio.to_thread(_fetch_user_profile_sync, user_id)1200 