-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
832 lines (737 loc) · 33.2 KB
/
Copy pathmain.py
File metadata and controls
832 lines (737 loc) · 33.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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
90
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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
from fastapi import FastAPI, File, UploadFile, HTTPException, Form
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import os
import json
import csv
import io
import re
import time
from typing import Optional, List, Dict, Any
import httpx
import asyncio
from datetime import datetime
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
import emoji
from collections import Counter
import base64
from reportlab.lib.pagesizes import letter, A4
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib import colors
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
from wordcloud import WordCloud
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use('Agg') # 使用非交互式后端
from io import BytesIO
app = FastAPI(title="聊天情绪分析助手", description="分析聊天记录中的情绪和意图,提供AI建议")
# 添加CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 挂载静态文件目录
app.mount("/static", StaticFiles(directory="static"), name="static")
# 数据模型
class AnalysisRequest(BaseModel):
chat_content: str
image_data: Optional[str] = None # base64编码的图片数据
api_key: Optional[str] = None
base_url: Optional[str] = "https://api.openai.com/v1"
model: Optional[str] = "gpt-3.5-turbo"
model_type: Optional[str] = "openai" # "openai" or "gemini"
class AnalysisResult(BaseModel):
emotions: Dict[str, float]
intent: str
sentiment_score: float
red_flags: List[str]
suggestions: List[str]
recommended_replies: List[str]
summary: str
emoji_analysis: Optional[Dict[str, Any]] = None
emotion_timeline: Optional[List[Dict[str, Any]]] = None
word_frequency: Optional[Dict[str, int]] = None
@app.get("/", response_class=HTMLResponse)
async def root():
"""返回主页面"""
return FileResponse("static/index.html")
@app.get("/health")
async def health_check():
"""健康检查接口"""
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
# 基础路由就绪,接下来实现核心功能
analyzer = SentimentIntensityAnalyzer()
def analyze_emoji_patterns(messages: List[str]) -> Dict[str, Any]:
"""分析消息中的表情符号使用模式和情感表达"""
emoji_counter = Counter()
emoji_emotions = {
'😊': 'positive', '😄': 'positive', '😃': 'positive', '😁': 'positive', '🙂': 'positive',
'😍': 'love', '🥰': 'love', '😘': 'love', '💕': 'love', '❤️': 'love',
'😢': 'sad', '😭': 'sad', '😞': 'sad', '😔': 'sad', '🥺': 'sad',
'😠': 'angry', '😡': 'angry', '🤬': 'angry', '😤': 'angry',
'😨': 'fear', '😰': 'fear', '😱': 'fear', '😧': 'fear',
'😴': 'tired', '😪': 'tired', '🥱': 'tired',
'🤔': 'thinking', '🧐': 'thinking', '💭': 'thinking',
'😂': 'laugh', '🤣': 'laugh', '😆': 'laugh'
}
emotion_counts = Counter()
total_emojis = 0
for message in messages:
# 提取所有emoji
emojis_in_message = emoji.emoji_list(message)
for emoji_data in emojis_in_message:
emoji_char = emoji_data['emoji']
emoji_counter[emoji_char] += 1
total_emojis += 1
# 映射到情感类别
if emoji_char in emoji_emotions:
emotion_counts[emoji_emotions[emoji_char]] += 1
# 计算情感分布
emotion_distribution = {}
if total_emojis > 0:
for emotion, count in emotion_counts.items():
emotion_distribution[emotion] = round(count / total_emojis, 3)
return {
'total_emojis': total_emojis,
'most_used_emojis': dict(emoji_counter.most_common(10)),
'emotion_distribution': emotion_distribution,
'emoji_diversity': len(emoji_counter),
'emoji_frequency': dict(emoji_counter)
}
def analyze_word_frequency(messages: List[str]) -> Dict[str, int]:
"""分析词频,用于生成词云图"""
import re
word_counter = Counter()
for message in messages:
# 移除emoji和特殊字符,只保留中英文和数字
clean_text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s]', '', message)
# 简单分词(按空格和标点分割)
words = re.findall(r'[\u4e00-\u9fa5]+|[a-zA-Z]+', clean_text)
for word in words:
if len(word) > 1: # 过滤单字符
word_counter[word.lower()] += 1
return dict(word_counter.most_common(50))
def create_emotion_timeline(messages: List[str]) -> List[Dict[str, Any]]:
"""创建情绪变化时间线"""
timeline = []
for i, message in enumerate(messages):
# 使用VADER分析每条消息的情绪
scores = analyzer.polarity_scores(message)
timeline.append({
'index': i,
'message_preview': message[:50] + '...' if len(message) > 50 else message,
'positive': round(scores['pos'], 3),
'negative': round(scores['neg'], 3),
'neutral': round(scores['neu'], 3),
'compound': round(scores['compound'], 3)
})
return timeline
def generate_pdf_report(analysis_result: AnalysisResult, filename: str = "analysis_report.pdf") -> str:
"""生成PDF分析报告"""
from reportlab.lib.pagesizes import letter, A4
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib import colors
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# 创建PDF文档
doc = SimpleDocTemplate(filename, pagesize=A4)
styles = getSampleStyleSheet()
story = []
# 标题
title_style = ParagraphStyle('CustomTitle', parent=styles['Heading1'], fontSize=24, spaceAfter=30)
story.append(Paragraph("聊天记录情感分析报告", title_style))
story.append(Spacer(1, 20))
# 基本信息
story.append(Paragraph("<b>分析概要</b>", styles['Heading2']))
story.append(Paragraph(f"意图分析: {analysis_result.intent}", styles['Normal']))
story.append(Paragraph(f"综合情感分: {analysis_result.sentiment_score:.3f}", styles['Normal']))
story.append(Spacer(1, 15))
# 情绪分布表格
story.append(Paragraph("<b>情绪分布</b>", styles['Heading2']))
emotion_data = [['情绪类型', '分值']]
for emotion, score in analysis_result.emotions.items():
emotion_data.append([emotion, f"{score:.3f}"])
emotion_table = Table(emotion_data)
emotion_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 14),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
('GRID', (0, 0), (-1, -1), 1, colors.black)
]))
story.append(emotion_table)
story.append(Spacer(1, 20))
# 风险提示
if analysis_result.red_flags:
story.append(Paragraph("<b>风险提示</b>", styles['Heading2']))
for flag in analysis_result.red_flags:
story.append(Paragraph(f"• {flag}", styles['Normal']))
story.append(Spacer(1, 15))
# 建议
if analysis_result.suggestions:
story.append(Paragraph("<b>建议</b>", styles['Heading2']))
for suggestion in analysis_result.suggestions:
story.append(Paragraph(f"• {suggestion}", styles['Normal']))
story.append(Spacer(1, 15))
# Emoji分析
if analysis_result.emoji_analysis:
story.append(Paragraph("<b>表情符号分析</b>", styles['Heading2']))
emoji_data = analysis_result.emoji_analysis
story.append(Paragraph(f"总表情符号数量: {emoji_data.get('total_emojis', 0)}", styles['Normal']))
story.append(Paragraph(f"表情符号多样性: {emoji_data.get('emoji_diversity', 0)}", styles['Normal']))
if emoji_data.get('most_used_emojis'):
story.append(Paragraph("最常用表情符号:", styles['Normal']))
for emoji_char, count in list(emoji_data['most_used_emojis'].items())[:5]:
story.append(Paragraph(f" {emoji_char}: {count}次", styles['Normal']))
doc.build(story)
return filename
def generate_excel_report(analysis_result: AnalysisResult, filename: str = "analysis_report.xlsx") -> str:
"""生成Excel分析报告"""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = Workbook()
# 概要页
ws_summary = wb.active
ws_summary.title = "分析概要"
# 设置标题
ws_summary['A1'] = "聊天记录情感分析报告"
ws_summary['A1'].font = Font(size=16, bold=True)
ws_summary['A1'].alignment = Alignment(horizontal='center')
# 基本信息
ws_summary['A3'] = "意图分析"
ws_summary['B3'] = analysis_result.intent
ws_summary['A4'] = "综合情感分"
ws_summary['B4'] = analysis_result.sentiment_score
# 情绪分布
ws_summary['A6'] = "情绪类型"
ws_summary['B6'] = "分值"
ws_summary['A6'].font = Font(bold=True)
ws_summary['B6'].font = Font(bold=True)
row = 7
for emotion, score in analysis_result.emotions.items():
ws_summary[f'A{row}'] = emotion
ws_summary[f'B{row}'] = round(score, 3)
row += 1
# 情绪时间线页
if analysis_result.emotion_timeline:
ws_timeline = wb.create_sheet("情绪时间线")
headers = ['序号', '消息预览', '积极', '消极', '中性', '综合分']
for col, header in enumerate(headers, 1):
ws_timeline.cell(row=1, column=col, value=header).font = Font(bold=True)
for row, data in enumerate(analysis_result.emotion_timeline, 2):
ws_timeline.cell(row=row, column=1, value=data['index'])
ws_timeline.cell(row=row, column=2, value=data['message_preview'])
ws_timeline.cell(row=row, column=3, value=data['positive'])
ws_timeline.cell(row=row, column=4, value=data['negative'])
ws_timeline.cell(row=row, column=5, value=data['neutral'])
ws_timeline.cell(row=row, column=6, value=data['compound'])
# Emoji分析页
if analysis_result.emoji_analysis:
ws_emoji = wb.create_sheet("表情符号分析")
ws_emoji['A1'] = "表情符号统计"
ws_emoji['A1'].font = Font(size=14, bold=True)
emoji_data = analysis_result.emoji_analysis
ws_emoji['A3'] = "总数量"
ws_emoji['B3'] = emoji_data.get('total_emojis', 0)
ws_emoji['A4'] = "多样性"
ws_emoji['B4'] = emoji_data.get('emoji_diversity', 0)
if emoji_data.get('most_used_emojis'):
ws_emoji['A6'] = "表情符号"
ws_emoji['B6'] = "使用次数"
ws_emoji['A6'].font = Font(bold=True)
ws_emoji['B6'].font = Font(bold=True)
row = 7
for emoji_char, count in emoji_data['most_used_emojis'].items():
ws_emoji[f'A{row}'] = emoji_char
ws_emoji[f'B{row}'] = count
row += 1
wb.save(filename)
return filename
# 简单规则解析:支持TXT、CSV、JSON三种格式
def parse_chat_text(text: str) -> List[Dict[str, Any]]:
messages = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
# 匹配“你:”或“对方:”前缀
m = re.match(r"^(你|对方|me|you|other)[::]\s*(.*)$", line, re.IGNORECASE)
if m:
sender = m.group(1)
content = m.group(2)
sender_norm = 'you' if sender in ['你','me'] else 'other'
messages.append({'sender': sender_norm, 'message': content})
else:
# 无前缀时,默认视为other的消息片段(合并)
if messages and messages[-1]['sender'] == 'other':
messages[-1]['message'] += ' ' + line
else:
messages.append({'sender': 'other', 'message': line})
return messages
def parse_chat_csv(text: str) -> List[Dict[str, Any]]:
messages = []
f = io.StringIO(text)
reader = csv.DictReader(f)
# 期待列名 sender, message
for row in reader:
sender = str(row.get('sender','')).strip().lower()
msg = str(row.get('message','')).strip()
if not msg:
continue
sender_norm = 'you' if sender in ['you','me','self','我','自己','你'] else 'other'
messages.append({'sender': sender_norm, 'message': msg})
return messages
def parse_chat_json(text: str) -> List[Dict[str, Any]]:
arr = json.loads(text)
messages = []
if isinstance(arr, list):
for item in arr:
if not isinstance(item, dict):
continue
sender = str(item.get('sender','')).strip().lower()
msg = str(item.get('message','')).strip()
if not msg:
continue
sender_norm = 'you' if sender in ['you','me','self','我','自己','你'] else 'other'
messages.append({'sender': sender_norm, 'message': msg})
return messages
def detect_format_and_parse(text: str) -> List[Dict[str, Any]]:
t = text.strip()
if not t:
return []
# 粗略判断
if t.startswith('[') or t.startswith('{'):
try:
return parse_chat_json(t)
except Exception:
pass
# 尝试CSV
try:
# 如果第一行包含sender与message
first = t.splitlines()[0]
if 'sender' in first and 'message' in first:
return parse_chat_csv(t)
except Exception:
pass
# 默认按TXT解析
return parse_chat_text(t)
# 统计基本情绪分数
def aggregate_emotions(messages: List[Dict[str, Any]]):
# 仅分析对方消息
other_msgs = [m['message'] for m in messages if m.get('sender') == 'other']
text = '\n'.join(other_msgs)
if not text:
return {
'sentiment_score': 0.0,
'emotions': {'neutral': 1.0},
'intent': '无法判断',
'red_flags': [],
}
vs = analyzer.polarity_scores(text)
compound = vs['compound']
# 映射为三类占比
pos = max(0.0, vs['pos'])
neu = max(0.0, vs['neu'])
neg = max(0.0, vs['neg'])
s = pos + neu + neg or 1.0
emotions = {
'positive': pos/s,
'neutral': neu/s,
'negative': neg/s,
}
red_flags = []
if compound < -0.5:
red_flags.append('明显负面/低落')
if any(k in text for k in ['生气','愤怒','失望','讨厌','分手','拉黑','烦','不想','算了']):
red_flags.append('存在冲突或拒绝信号')
if any(k in text for k in ['焦虑','压力','抑郁','绝望','无助']):
red_flags.append('情绪健康风险')
# 通过简单关键词推断意图
intent = '交流' \
if any(k in text for k in ['聊聊','沟通','说说','想法','建议']) else \
'拒绝/推迟' if any(k in text for k in ['不行','不想','推迟','改天','下次','以后']) else \
'求助/支持' if any(k in text for k in ['帮忙','帮助','支持','需要']) else \
('情感低落' if compound < -0.3 else ('积极响应' if compound > 0.3 else '中性/观望'))
return {
'sentiment_score': compound,
'emotions': emotions,
'intent': intent,
'red_flags': red_flags,
}
async def call_openai_compatible(messages: List[Dict[str, Any]], base_url: str, model: str, api_key: str) -> Dict[str, Any]:
# 构造简化的对话上下文
system_prompt = (
"你是一个聊天情绪与意图分析助手。基于‘对方’的消息,总结:\n"
"1) 主要情绪(积极/中性/消极的比例),\n"
"2) 核心意图(如积极响应、拒绝/推迟、求助/支持、闲聊等),\n"
"3) 潜在风险红旗,\n"
"4) 提供3-5条可执行建议,\n"
"5) 生成3条礼貌、共情且具体的推荐回复。\n"
"请使用简洁中文输出一个JSON,不要多余文本,字段为: emotions{positive,neutral,negative}, intent, red_flags[], suggestions[], recommended_replies[], summary, sentiment_score(-1..1)。"
)
user_text = "\n".join([f"{m['sender']}: {m['message']}" for m in messages])
# 优先走OpenAI Chat Completions,如果兼容
url = base_url.rstrip('/') + '/chat/completions'
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
payload = {
'model': model,
'messages': [
{ 'role': 'system', 'content': system_prompt },
{ 'role': 'user', 'content': user_text }
],
'temperature': 0.2
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
content = data['choices'][0]['message']['content']
# 解析为JSON
try:
return json.loads(content)
except Exception:
# 尝试提取JSON
m = re.search(r"\{[\s\S]*\}", content)
if m:
return json.loads(m.group(0))
raise HTTPException(status_code=502, detail='模型返回非JSON内容')
async def call_openai_compatible_with_image(image_data: str, base_url: str, model: str, api_key: str) -> Dict[str, Any]:
"""使用OpenAI兼容API分析图片中的聊天内容"""
system_prompt = (
"你是一个聊天情绪与意图分析助手。请分析这张聊天截图中'对方'的消息,总结:\n"
"1) 主要情绪(积极/中性/消极的比例),\n"
"2) 核心意图(如积极响应、拒绝/推迟、求助/支持、闲聊等),\n"
"3) 潜在风险红旗,\n"
"4) 提供3-5条可执行建议,\n"
"5) 生成3条礼貌、共情且具体的推荐回复。\n"
"请使用简洁中文输出一个JSON,不要多余文本,字段为: emotions{positive,neutral,negative}, intent, red_flags[], suggestions[], recommended_replies[], summary, sentiment_score(-1..1)。"
)
url = base_url.rstrip('/') + '/chat/completions'
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
payload = {
'model': model,
'messages': [
{ 'role': 'system', 'content': system_prompt },
{
'role': 'user',
'content': [
{
'type': 'text',
'text': '请分析这张聊天截图中的情绪和意图:'
},
{
'type': 'image_url',
'image_url': {
'url': f'data:image/jpeg;base64,{image_data}'
}
}
]
}
],
'temperature': 0.2,
'max_tokens': 1500
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
content = data['choices'][0]['message']['content']
# 解析为JSON
try:
return json.loads(content)
except Exception:
# 尝试提取JSON
m = re.search(r"\{[\s\S]*\}", content)
if m:
return json.loads(m.group(0))
raise HTTPException(status_code=502, detail='模型返回非JSON内容')
async def call_gemini_api_with_image(image_data: str, model: str, api_key: str) -> Dict[str, Any]:
"""使用Gemini API分析图片中的聊天内容"""
system_prompt = (
"你是一个聊天情绪与意图分析助手。请分析这张聊天截图中'对方'的消息,总结:\n"
"1) 主要情绪(积极/中性/消极的比例),\n"
"2) 核心意图(如积极响应、拒绝/推迟、求助/支持、闲聊等),\n"
"3) 潜在风险红旗,\n"
"4) 提供3-5条可执行建议,\n"
"5) 生成3条礼貌、共情且具体的推荐回复。\n"
"请使用简洁中文输出一个JSON,不要多余文本,字段为: emotions{positive,neutral,negative}, intent, red_flags[], suggestions[], recommended_replies[], summary, sentiment_score(-1..1)。"
)
# Gemini API URL
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
headers = {'Content-Type': 'application/json'}
# 构造Gemini的请求格式(支持图片)
payload = {
"contents": [
{
"parts": [
{"text": f"{system_prompt}\n\n请分析这张聊天截图:"},
{
"inline_data": {
"mime_type": "image/jpeg",
"data": image_data
}
}
]
}
],
"generationConfig": {
"temperature": 0.2,
"topK": 1,
"topP": 1,
"maxOutputTokens": 2048,
}
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
content = data['candidates'][0]['content']['parts'][0]['text']
# 解析为JSON
try:
return json.loads(content)
except Exception:
# 尝试提取JSON
m = re.search(r"\{[\s\S]*\}", content)
if m:
return json.loads(m.group(0))
raise HTTPException(status_code=502, detail='模型返回非JSON内容')
async def call_gemini_api(messages: List[Dict[str, Any]], model: str, api_key: str) -> Dict[str, Any]:
# 构造Gemini API调用
system_prompt = (
"你是一个聊天情绪与意图分析助手。基于'对方'的消息,总结:\n"
"1) 主要情绪(积极/中性/消极的比例),\n"
"2) 核心意图(如积极响应、拒绝/推迟、求助/支持、闲聊等),\n"
"3) 潜在风险红旗,\n"
"4) 提供3-5条可执行建议,\n"
"5) 生成3条礼貌、共情且具体的推荐回复。\n"
"请使用简洁中文输出一个JSON,不要多余文本,字段为: emotions{positive,neutral,negative}, intent, red_flags[], suggestions[], recommended_replies[], summary, sentiment_score(-1..1)。"
)
user_text = "\n".join([f"{m['sender']}: {m['message']}" for m in messages])
# Gemini API URL
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
headers = {'Content-Type': 'application/json'}
# 构造Gemini的请求格式
payload = {
"contents": [
{
"parts": [
{"text": f"{system_prompt}\n\n聊天内容:\n{user_text}"}
]
}
],
"generationConfig": {
"temperature": 0.2,
"topK": 1,
"topP": 1,
"maxOutputTokens": 2048,
}
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
content = data['candidates'][0]['content']['parts'][0]['text']
# 解析为JSON
try:
return json.loads(content)
except Exception:
# 尝试提取JSON
m = re.search(r"\{[\s\S]*\}", content)
if m:
return json.loads(m.group(0))
raise HTTPException(status_code=502, detail='模型返回非JSON内容')
@app.post('/api/analyze', response_model=AnalysisResult)
async def analyze(req: AnalysisRequest):
# 检查是否为图片分析
if req.image_data:
# 图片分析模式
base = {
'emotions': {'positive': 0.33, 'neutral': 0.34, 'negative': 0.33},
'intent': '图片分析中...',
'sentiment_score': 0.0,
'red_flags': []
}
else:
# 文本分析模式
messages = detect_format_and_parse(req.chat_content)
if not messages:
raise HTTPException(status_code=400, detail='无法解析聊天记录')
base = aggregate_emotions(messages)
result: Dict[str, Any] = {
'emotions': base['emotions'],
'intent': base['intent'],
'sentiment_score': base['sentiment_score'],
'red_flags': base['red_flags'],
'suggestions': [],
'recommended_replies': [],
'summary': ''
}
# 统一处理密钥与Base URL的回退
model_type = (req.model_type or 'openai').lower()
effective_key = (req.api_key or (
os.getenv('GEMINI_API_KEY') if model_type == 'gemini' else (os.getenv('OPENAI_API_KEY') or os.getenv('AI_API_KEY'))
))
effective_base_url = (req.base_url or os.getenv('OPENAI_BASE_URL') or 'https://api.openai.com/v1')
# 如果提供了Key与模型,则增强分析
if effective_key:
try:
if req.image_data:
# 图片分析模式
if model_type == "gemini":
enhanced = await call_gemini_api_with_image(req.image_data, req.model or 'gemini-1.5-flash', effective_key)
else:
enhanced = await call_openai_compatible_with_image(req.image_data, effective_base_url, req.model or 'gpt-4-vision-preview', effective_key)
else:
# 文本分析模式
if model_type == "gemini":
enhanced = await call_gemini_api(messages, req.model or 'gemini-1.5-flash', effective_key)
else:
enhanced = await call_openai_compatible(messages, effective_base_url, req.model or 'gpt-3.5-turbo', effective_key)
# 合并字段
for k in ['emotions','intent','sentiment_score','red_flags','suggestions','recommended_replies','summary']:
if k in enhanced and enhanced[k] is not None:
result[k] = enhanced[k]
# 添加高级分析功能(仅在文本分析模式下)
if not req.image_data and messages:
# 提取所有消息文本用于分析
all_messages = [m['message'] for m in messages]
# 添加emoji分析
result['emoji_analysis'] = analyze_emoji_patterns(all_messages)
# 添加词频分析
result['word_frequency'] = analyze_word_frequency(all_messages)
# 添加情绪时间线
result['emotion_timeline'] = create_emotion_timeline(all_messages)
except Exception as e:
# 模型失败则保留基础结果
error_msg = str(e)
if '401' in error_msg or 'Unauthorized' in error_msg:
result.setdefault('red_flags', []).append('API密钥无效或未授权,请检查API Key配置')
elif '403' in error_msg or 'Forbidden' in error_msg:
result.setdefault('red_flags', []).append('API密钥权限不足,请检查密钥权限设置')
elif '429' in error_msg:
result.setdefault('red_flags', []).append('API调用频率超限,请稍后重试')
else:
result.setdefault('red_flags', []).append(f'模型调用失败: {error_msg[:100]},已返回基础分析')
else:
# 无Key时,生成简单建议与回复
if req.image_data:
# 图片分析但无API Key的情况
result['suggestions'] = [
'建议配置API Key以获得准确的图片分析结果',
'可以尝试将聊天截图转换为文本格式进行分析',
'如果是重要对话,建议使用支持视觉的AI模型'
]
result['recommended_replies'] = [
'我看到了你分享的聊天截图,让我仔细看看',
'基于图片内容,我需要更多信息才能给出准确建议',
'要不你把关键对话内容用文字描述一下?'
]
result['summary'] = '已接收聊天截图,但需要AI视觉模型才能进行详细分析。'
result['intent'] = '图片内容分析(需要API Key)'
elif result['sentiment_score'] < -0.3:
result['suggestions'] = [
'先表达理解与共情,避免争辩,给出具体支持方式',
'如果对方压力大,建议先缓一缓,择期再谈',
'避免多问责备,强调你愿意倾听'
]
result['recommended_replies'] = [
'我能感觉到你最近不太顺利,如果你愿意,我愿意听你多说说',
'要不要先休息一下?不急着定结论,我们可以改天再聊',
'我会尽量配合你的节奏,如果有我能帮上的地方告诉我'
]
result['summary'] = '对方整体情绪偏负面,可能存在压力或挫败感,需要更多耐心与支持。'
elif result['sentiment_score'] > 0.3:
result['suggestions'] = [
'顺势推进议题,明确下一步时间与事项',
'表达肯定与感谢,巩固积极互动',
'用具体事实或例子回应,避免空泛'
]
result['recommended_replies'] = [
'太好了,我们就按这个思路走,我来准备一下具体方案',
'谢谢你的反馈,很受用!要不我们定在周三晚再聊细节?',
'听起来挺不错的,我也很期待接下来的合作'
]
result['summary'] = '对方整体情绪偏积极,适合推进沟通与落地安排。'
else:
result['suggestions'] = [
'先澄清关键点,避免各说各话',
'询问对方真实顾虑,给到可选择的选项',
'避免长篇大论,简洁清晰地表达'
]
result['recommended_replies'] = [
'我理解你的考虑点,方便说说最担心的是哪一部分吗?',
'我们可以有A/B两个方案,你更倾向哪一种?',
'为了不占用你太多时间,我先列三点要点,看看是否准确'
]
result['summary'] = '对方情绪中性或观望,适合先澄清与对齐预期。'
# 归一化情绪与类型修正保持不变
emos = result.get('emotions') or {}
s = sum([v for v in emos.values() if isinstance(v,(int,float))]) or 1.0
for k in list(emos.keys()):
v = emos[k]
if isinstance(v,(int,float)):
emos[k] = max(0.0, float(v)) / s
result['emotions'] = emos
result['red_flags'] = list(dict.fromkeys(result.get('red_flags') or []))
result['suggestions'] = list(dict.fromkeys(result.get('suggestions') or []))
result['recommended_replies'] = list(dict.fromkeys(result.get('recommended_replies') or []))
result['intent'] = str(result.get('intent') or '未知')
result['summary'] = str(result.get('summary') or '')
result['sentiment_score'] = float(result.get('sentiment_score') or 0.0)
return result
@app.post("/api/export/pdf")
async def export_pdf(analysis_data: dict):
"""导出PDF报告"""
try:
# 将字典转换为AnalysisResult对象
analysis_result = AnalysisResult(**analysis_data)
# 生成PDF文件
filename = f"analysis_report_{int(time.time())}.pdf"
filepath = generate_pdf_report(analysis_result, filename)
# 返回文件
return FileResponse(
path=filepath,
filename=filename,
media_type='application/pdf'
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"PDF生成失败: {str(e)}")
@app.post("/api/export/excel")
async def export_excel(analysis_data: dict):
"""导出Excel报告"""
try:
# 将字典转换为AnalysisResult对象
analysis_result = AnalysisResult(**analysis_data)
# 生成Excel文件
filename = f"analysis_report_{int(time.time())}.xlsx"
filepath = generate_excel_report(analysis_result, filename)
# 返回文件
return FileResponse(
path=filepath,
filename=filename,
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Excel生成失败: {str(e)}")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)