344 lines
12 KiB
Python
344 lines
12 KiB
Python
|
|
"""统计服务:日志查询、趋势数据、知识库健康度、Token 成本。"""
|
|||
|
|
import csv
|
|||
|
|
import io
|
|||
|
|
from insurance.db.compat import db
|
|||
|
|
from sqlalchemy import text
|
|||
|
|
|
|||
|
|
|
|||
|
|
class StatsService:
|
|||
|
|
"""统计报表业务逻辑。"""
|
|||
|
|
|
|||
|
|
def get_chat_logs(self, params: dict) -> dict:
|
|||
|
|
"""查询对话记录(从本地 insurance_chat_records 表获取)。"""
|
|||
|
|
# 构建 WHERE 条件
|
|||
|
|
conditions = ["u.role = 'user'"]
|
|||
|
|
bind_params = {}
|
|||
|
|
|
|||
|
|
if params.get("user_id"):
|
|||
|
|
conditions.append("u.user_id = :user_id")
|
|||
|
|
bind_params["user_id"] = params["user_id"]
|
|||
|
|
if params.get("start_date"):
|
|||
|
|
conditions.append("u.created_at >= :start_date")
|
|||
|
|
bind_params["start_date"] = params["start_date"]
|
|||
|
|
if params.get("end_date"):
|
|||
|
|
conditions.append("u.created_at <= :end_date")
|
|||
|
|
bind_params["end_date"] = params["end_date"] + " 23:59:59"
|
|||
|
|
if params.get("keyword"):
|
|||
|
|
conditions.append("""
|
|||
|
|
(u.content LIKE :keyword
|
|||
|
|
OR EXISTS (
|
|||
|
|
SELECT 1 FROM insurance_chat_records a2
|
|||
|
|
WHERE a2.session_id = u.session_id
|
|||
|
|
AND a2.role = 'assistant'
|
|||
|
|
AND a2.content LIKE :keyword
|
|||
|
|
))
|
|||
|
|
""")
|
|||
|
|
bind_params["keyword"] = f"%{params['keyword']}%"
|
|||
|
|
|
|||
|
|
where_clause = " AND ".join(conditions)
|
|||
|
|
|
|||
|
|
# 查询总数(只计算用户消息数,每条用户消息代表一个问答对)
|
|||
|
|
count_sql = text(f"""
|
|||
|
|
SELECT COUNT(*) as total
|
|||
|
|
FROM insurance_chat_records u
|
|||
|
|
WHERE {where_clause}
|
|||
|
|
""")
|
|||
|
|
total_result = db.session.execute(count_sql, bind_params)
|
|||
|
|
total = total_result.scalar() or 0
|
|||
|
|
|
|||
|
|
# 查询数据(用户问题 + 助手回答配对)
|
|||
|
|
# 使用子查询确保每条用户消息只匹配一条助手消息(兼容 PostgreSQL 和 SQLite)
|
|||
|
|
query_sql = text(f"""
|
|||
|
|
SELECT
|
|||
|
|
u.id,
|
|||
|
|
u.user_id as "user",
|
|||
|
|
u.content as query,
|
|||
|
|
COALESCE(
|
|||
|
|
(SELECT a.content FROM insurance_chat_records a
|
|||
|
|
WHERE a.session_id = u.session_id
|
|||
|
|
AND a.role = 'assistant'
|
|||
|
|
AND a.created_at > u.created_at
|
|||
|
|
ORDER BY a.created_at ASC LIMIT 1),
|
|||
|
|
''
|
|||
|
|
) as answer,
|
|||
|
|
COALESCE(
|
|||
|
|
(SELECT a.rating FROM insurance_chat_records a
|
|||
|
|
WHERE a.session_id = u.session_id
|
|||
|
|
AND a.role = 'assistant'
|
|||
|
|
AND a.created_at > u.created_at
|
|||
|
|
ORDER BY a.created_at ASC LIMIT 1),
|
|||
|
|
''
|
|||
|
|
) as feedback,
|
|||
|
|
u.created_at
|
|||
|
|
FROM insurance_chat_records u
|
|||
|
|
WHERE {where_clause}
|
|||
|
|
ORDER BY u.created_at DESC
|
|||
|
|
LIMIT :limit OFFSET :offset
|
|||
|
|
""")
|
|||
|
|
bind_params["limit"] = params["page_size"]
|
|||
|
|
bind_params["offset"] = (params["page"] - 1) * params["page_size"]
|
|||
|
|
|
|||
|
|
result = db.session.execute(query_sql, bind_params)
|
|||
|
|
items = [row._asdict() for row in result]
|
|||
|
|
|
|||
|
|
# 格式化时间字段
|
|||
|
|
for item in items:
|
|||
|
|
if item.get("created_at"):
|
|||
|
|
item["created_at"] = str(item["created_at"])
|
|||
|
|
|
|||
|
|
return {"code": 0, "data": {"items": items, "total": total}}
|
|||
|
|
|
|||
|
|
def export_chat_logs(self, params: dict) -> str:
|
|||
|
|
"""导出问答记录为 CSV。"""
|
|||
|
|
result = self.get_chat_logs({**params, "page": 1, "page_size": 10000})
|
|||
|
|
output = io.StringIO()
|
|||
|
|
writer = csv.writer(output)
|
|||
|
|
writer.writerow(["ID", "用户", "问题", "回答", "评分", "时间"])
|
|||
|
|
for item in result.get("data", {}).get("items", []):
|
|||
|
|
writer.writerow([
|
|||
|
|
item.get("id", ""),
|
|||
|
|
item.get("user", ""),
|
|||
|
|
item.get("query", ""),
|
|||
|
|
item.get("answer", ""),
|
|||
|
|
item.get("feedback", ""),
|
|||
|
|
item.get("created_at", ""),
|
|||
|
|
])
|
|||
|
|
return output.getvalue()
|
|||
|
|
|
|||
|
|
def get_system_logs(self, params: dict) -> dict:
|
|||
|
|
"""查询系统操作日志。"""
|
|||
|
|
# 构建WHERE条件
|
|||
|
|
conditions = ["1=1"]
|
|||
|
|
bind_params = {}
|
|||
|
|
|
|||
|
|
if params.get("action"):
|
|||
|
|
conditions.append("action = :action")
|
|||
|
|
bind_params["action"] = params["action"]
|
|||
|
|
if params.get("user_id"):
|
|||
|
|
conditions.append("user_id = :user_id")
|
|||
|
|
bind_params["user_id"] = params["user_id"]
|
|||
|
|
|
|||
|
|
where_clause = " AND ".join(conditions)
|
|||
|
|
|
|||
|
|
# 查询总数
|
|||
|
|
count_sql = text(f"""
|
|||
|
|
SELECT COUNT(*) as total
|
|||
|
|
FROM system_operation_logs
|
|||
|
|
WHERE {where_clause}
|
|||
|
|
""")
|
|||
|
|
total_result = db.session.execute(count_sql, bind_params)
|
|||
|
|
total = total_result.scalar() or 0
|
|||
|
|
|
|||
|
|
# 查询数据
|
|||
|
|
query_sql = text(f"""
|
|||
|
|
SELECT id, user_id, action, target_type, target_id, detail, ip, user_agent, created_at
|
|||
|
|
FROM system_operation_logs
|
|||
|
|
WHERE {where_clause}
|
|||
|
|
ORDER BY created_at DESC
|
|||
|
|
LIMIT :limit OFFSET :offset
|
|||
|
|
""")
|
|||
|
|
bind_params["limit"] = params["page_size"]
|
|||
|
|
bind_params["offset"] = (params["page"] - 1) * params["page_size"]
|
|||
|
|
|
|||
|
|
result = db.session.execute(query_sql, bind_params)
|
|||
|
|
items = [row._asdict() for row in result]
|
|||
|
|
|
|||
|
|
return {"code": 0, "data": {"total": total, "items": items}}
|
|||
|
|
|
|||
|
|
def get_overview(self) -> dict:
|
|||
|
|
"""使用概览:今日问答数、活跃用户、知识库命中率。"""
|
|||
|
|
import requests
|
|||
|
|
from flask import current_app
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
|
|||
|
|
base_url = current_app.config.get("BAODAN_API_URL", "http://localhost:5001")
|
|||
|
|
api_key = current_app.config.get("BAODAN_CHAT_API_KEY", "")
|
|||
|
|
|
|||
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|||
|
|
result = {
|
|||
|
|
"today_chats": 0,
|
|||
|
|
"active_users": 0,
|
|||
|
|
"kb_hit_rate": 0,
|
|||
|
|
"total_documents": 0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 获取今日对话数
|
|||
|
|
resp = requests.get(
|
|||
|
|
f"{base_url}/v1/messages",
|
|||
|
|
params={"limit": 1, "user": ""},
|
|||
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|||
|
|
timeout=10,
|
|||
|
|
)
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
data = resp.json()
|
|||
|
|
result["today_chats"] = data.get("total", 0)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 获取活跃用户数(从本地数据库统计今日登录用户)
|
|||
|
|
from insurance.models.wecom_user import WeComUserMapping
|
|||
|
|
from insurance.db.compat import db
|
|||
|
|
|
|||
|
|
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|||
|
|
active_users = db.session.query(WeComUserMapping).filter(
|
|||
|
|
WeComUserMapping.last_active_at >= today_start
|
|||
|
|
).count()
|
|||
|
|
result["active_users"] = active_users
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 获取文档总数
|
|||
|
|
resp = requests.get(
|
|||
|
|
f"{base_url}/v1/datasets",
|
|||
|
|
params={"page": 1, "limit": 1},
|
|||
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|||
|
|
timeout=10,
|
|||
|
|
)
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
data = resp.json()
|
|||
|
|
result["total_documents"] = data.get("total", 0)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return {"code": 0, "data": result}
|
|||
|
|
|
|||
|
|
def get_trend(self, metric: str, start_date: str, end_date: str, granularity: str) -> dict:
|
|||
|
|
"""趋势数据(折线图数据源)。"""
|
|||
|
|
import requests
|
|||
|
|
from flask import current_app
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
|
|||
|
|
base_url = current_app.config.get("BAODAN_API_URL", "http://localhost:5001")
|
|||
|
|
api_key = current_app.config.get("BAODAN_CHAT_API_KEY", "")
|
|||
|
|
|
|||
|
|
# 如果未提供日期,默认最近 30 天
|
|||
|
|
now = datetime.now()
|
|||
|
|
if not start_date or not end_date:
|
|||
|
|
end = now
|
|||
|
|
start = now - timedelta(days=30)
|
|||
|
|
else:
|
|||
|
|
try:
|
|||
|
|
start = datetime.strptime(start_date, "%Y-%m-%d")
|
|||
|
|
end = datetime.strptime(end_date, "%Y-%m-%d")
|
|||
|
|
except ValueError:
|
|||
|
|
return {"code": 1001, "message": "日期格式错误,请使用 YYYY-MM-DD 格式", "data": None}
|
|||
|
|
|
|||
|
|
data_points = []
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
current = start
|
|||
|
|
while current <= end:
|
|||
|
|
date_str = current.strftime("%Y-%m-%d")
|
|||
|
|
|
|||
|
|
# 简化实现:每天返回一个模拟数据点
|
|||
|
|
# 实际应该从BaoDan API获取真实数据
|
|||
|
|
data_points.append({
|
|||
|
|
"date": date_str,
|
|||
|
|
"value": 0, # TODO: 从API获取真实数据
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if granularity == "day":
|
|||
|
|
current += timedelta(days=1)
|
|||
|
|
elif granularity == "week":
|
|||
|
|
current += timedelta(weeks=1)
|
|||
|
|
elif granularity == "month":
|
|||
|
|
current += timedelta(days=30)
|
|||
|
|
else:
|
|||
|
|
current += timedelta(days=1)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
return {"code": 5001, "message": f"获取趋势数据失败: {str(e)}", "data": None}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"code": 0,
|
|||
|
|
"data": {
|
|||
|
|
"metric": metric,
|
|||
|
|
"granularity": granularity,
|
|||
|
|
"data": data_points,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def get_kb_health(self, days: int) -> dict:
|
|||
|
|
"""知识库健康度。"""
|
|||
|
|
import requests
|
|||
|
|
from flask import current_app
|
|||
|
|
|
|||
|
|
base_url = current_app.config.get("BAODAN_API_URL", "http://localhost:5001")
|
|||
|
|
api_key = current_app.config.get("BAODAN_CHAT_API_KEY", "")
|
|||
|
|
|
|||
|
|
result = {
|
|||
|
|
"hot_questions": [],
|
|||
|
|
"missed_questions": [],
|
|||
|
|
"coverage": {},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 获取热门问题(从对话日志中统计)
|
|||
|
|
resp = requests.get(
|
|||
|
|
f"{base_url}/v1/messages",
|
|||
|
|
params={"limit": 100, "user": ""},
|
|||
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|||
|
|
timeout=10,
|
|||
|
|
)
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
messages = resp.json().get("data", [])
|
|||
|
|
# 统计问题频次
|
|||
|
|
question_counts = {}
|
|||
|
|
for msg in messages:
|
|||
|
|
query = msg.get("query", "")
|
|||
|
|
if query:
|
|||
|
|
question_counts[query] = question_counts.get(query, 0) + 1
|
|||
|
|
|
|||
|
|
# 取Top20
|
|||
|
|
sorted_questions = sorted(question_counts.items(), key=lambda x: x[1], reverse=True)[:20]
|
|||
|
|
result["hot_questions"] = [
|
|||
|
|
{"question": q, "count": c}
|
|||
|
|
for q, c in sorted_questions
|
|||
|
|
]
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return {"code": 0, "data": result}
|
|||
|
|
|
|||
|
|
def get_token_cost(self, start_date: str, end_date: str, group_by: str) -> dict:
|
|||
|
|
"""Token 消耗统计。"""
|
|||
|
|
import requests
|
|||
|
|
from flask import current_app
|
|||
|
|
|
|||
|
|
base_url = current_app.config.get("BAODAN_API_URL", "http://localhost:5001")
|
|||
|
|
api_key = current_app.config.get("BAODAN_CHAT_API_KEY", "")
|
|||
|
|
|
|||
|
|
result = {
|
|||
|
|
"total_tokens": 0,
|
|||
|
|
"total_cost_usd": 0,
|
|||
|
|
"by_model": [],
|
|||
|
|
"daily_trend": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 从BaoDan API获取Token使用统计
|
|||
|
|
# 注意:BaoDan API可能不直接提供此数据,需要根据实际情况调整
|
|||
|
|
resp = requests.get(
|
|||
|
|
f"{base_url}/v1/messages",
|
|||
|
|
params={"limit": 1000, "user": ""},
|
|||
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|||
|
|
timeout=10,
|
|||
|
|
)
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
messages = resp.json().get("data", [])
|
|||
|
|
total_tokens = 0
|
|||
|
|
for msg in messages:
|
|||
|
|
usage = msg.get("metadata", {}).get("usage", {})
|
|||
|
|
total_tokens += usage.get("total_tokens", 0)
|
|||
|
|
|
|||
|
|
result["total_tokens"] = total_tokens
|
|||
|
|
# 估算费用(假设平均$0.002/1K tokens)
|
|||
|
|
result["total_cost_usd"] = round(total_tokens * 0.000002, 2)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return {"code": 0, "data": result}
|