baodan/api/insurance/stats/service.py
2026-07-12 14:17:18 +08:00

525 lines
21 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""统计服务日志查询、趋势数据、知识库健康度、Token 成本。"""
import csv
import io
import json
from insurance.db.compat import db
from sqlalchemy import text
class StatsService:
"""统计报表业务逻辑。"""
def _parse_model_name(self, provider: str = "", model_id: str = "", model_json=None) -> str:
"""Extract provider/model from Dify app model config fields."""
if provider and model_id:
return f"{provider}/{model_id}"
if not model_json:
return ""
try:
model_data = json.loads(model_json) if isinstance(model_json, str) and model_json.startswith("{") else model_json
except (json.JSONDecodeError, TypeError):
return ""
if not isinstance(model_data, dict):
return ""
parsed_provider = model_data.get("provider", "")
parsed_model = model_data.get("name", "") or model_data.get("model", "") or model_data.get("model_id", "")
if parsed_provider and parsed_model and not isinstance(parsed_model, dict):
return f"{parsed_provider}/{parsed_model}"
nested = model_data.get("model", {})
if isinstance(nested, dict):
nested_provider = nested.get("provider", "") or parsed_provider
nested_model = nested.get("name", "") or nested.get("model", "") or nested.get("model_id", "")
if nested_provider and nested_model:
return f"{nested_provider}/{nested_model}"
return ""
def _get_default_model_name(self) -> str:
"""Get the latest configured app model name for records saved before model_id was populated."""
try:
result = db.session.execute(text("""
SELECT provider, model_id, model
FROM app_model_configs
ORDER BY updated_at DESC
""")).fetchall()
for provider, model_id, model_json in result:
model_name = self._parse_model_name(provider, model_id, model_json)
if model_name:
return model_name
except Exception:
pass
return "unknown"
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']}%"
if params.get("rating"):
conditions.append("""
EXISTS (
SELECT 1 FROM insurance_chat_records a3
WHERE a3.session_id = u.session_id
AND a3.role = 'assistant'
AND a3.rating = :rating
)
""")
bind_params["rating"] = params["rating"]
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_id,
COALESCE(NULLIF(m.real_name, ''), NULLIF(m.username, ''), u.user_id) as "user",
COALESCE(NULLIF(m.real_name, ''), NULLIF(m.username, ''), u.user_id) as user_name,
m.username as username,
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,
COALESCE(
(SELECT a.correction 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 correction,
u.created_at
FROM insurance_chat_records u
LEFT JOIN wecom_user_mapping m ON u.user_id = CAST(m.id AS TEXT)
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带 UTF-8 BOM解决 Excel 乱码问题)。"""
result = self.get_chat_logs({**params, "page": 1, "page_size": 10000})
output = io.StringIO()
# 添加 UTF-8 BOM让 Excel 正确识别中文编码
output.write('')
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("correction", ""),
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:
"""使用概览:今日问答数、活跃用户、问答记录总数。"""
from datetime import datetime
result = {
"today_chats": 0,
"active_users": 0,
"total_documents": 0,
}
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
try:
# 今日问答数:统计 insurance_chat_records 表中今日的用户消息数
count_sql = text("""
SELECT COUNT(*) as total
FROM insurance_chat_records
WHERE role = 'user'
AND created_at >= :today_start
""")
total_result = db.session.execute(count_sql, {"today_start": today_start})
result["today_chats"] = total_result.scalar() or 0
except Exception:
pass
try:
# 活跃用户数:统计今日在 insurance_chat_records 表中有消息的去重用户数
active_sql = text("""
SELECT COUNT(DISTINCT user_id) as total
FROM insurance_chat_records
WHERE role = 'user'
AND created_at >= :today_start
""")
active_result = db.session.execute(active_sql, {"today_start": today_start})
result["active_users"] = active_result.scalar() or 0
except Exception:
pass
try:
# 文档总数:统计所有历史问答对数量(每条用户消息代表一个问答对)
doc_sql = text("""
SELECT COUNT(*) as total
FROM insurance_chat_records
WHERE role = 'user'
""")
doc_result = db.session.execute(doc_sql)
result["total_documents"] = doc_result.scalar() or 0
except Exception:
pass
return {"code": 0, "data": result}
def get_trend(self, metric: str, start_date: str, end_date: str, granularity: str) -> dict:
"""趋势数据(折线图数据源)。"""
from datetime import datetime, timedelta
# 如果未提供日期,默认最近 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}
# 根据指标选择查询(使用 insurance_chat_records 表)
if metric == "chat_count":
# 每日问答数(只统计用户消息,每条代表一个问答对)
sql = text("""
SELECT DATE(created_at) as date, COUNT(*) as value
FROM insurance_chat_records
WHERE role = 'user'
AND created_at >= :start_date AND created_at <= :end_date
GROUP BY DATE(created_at)
ORDER BY date DESC
""")
elif metric == "active_users":
# 每日活跃用户数
sql = text("""
SELECT DATE(created_at) as date, COUNT(DISTINCT user_id) as value
FROM insurance_chat_records
WHERE role = 'user'
AND created_at >= :start_date AND created_at <= :end_date
GROUP BY DATE(created_at)
ORDER BY date DESC
""")
else:
return {"code": 1001, "message": f"不支持的指标: {metric}", "data": None}
try:
result = db.session.execute(sql, {
"start_date": start.strftime("%Y-%m-%d"),
"end_date": end.strftime("%Y-%m-%d") + " 23:59:59",
}).fetchall()
# 转换为字典,方便查找
data_map = {row[0].strftime("%Y-%m-%d") if hasattr(row[0], 'strftime') else str(row[0]): row[1] for row in result}
# 补全日期(没有数据的日期填 0
data_points = []
current = start
while current <= end:
date_str = current.strftime("%Y-%m-%d")
data_points.append({
"date": date_str,
"value": data_map.get(date_str, 0),
})
current += timedelta(days=1)
data_points.reverse()
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_usage(self, params: dict) -> dict:
"""Token 使用统计:总量 + 按模型分组 + 按用户分组 + 日趋势。
查询 insurance_chat_records 表。
"""
import logging
logging.info(f"[TOKEN_USAGE] 收到请求: params={params}")
default_model = self._get_default_model_name()
conditions = ["1=1"]
bind_params = {"default_model": default_model}
# 时间范围筛选
if params.get("start_date"):
conditions.append("created_at >= :start_date")
bind_params["start_date"] = params["start_date"]
if params.get("end_date"):
conditions.append("created_at <= :end_date")
bind_params["end_date"] = params["end_date"] + " 23:59:59"
# 模型筛选
if params.get("models"):
model_list = params["models"]
placeholders = ", ".join([f":model_{i}" for i in range(len(model_list))])
conditions.append(f"COALESCE(NULLIF(model_id, ''), :default_model) IN ({placeholders})")
for i, model in enumerate(model_list):
bind_params[f"model_{i}"] = model
where_clause = " AND ".join(conditions)
logging.info(f"[TOKEN_USAGE] SQL WHERE: {where_clause}, params={bind_params}")
try:
# 1. 总量统计(只统计 assistant 消息的 answer_tokensuser 消息的 message_tokens
total_sql = text(f"""
SELECT
COALESCE(SUM(CASE WHEN role = 'assistant' THEN answer_tokens ELSE message_tokens END), 0) as total_tokens,
COALESCE(SUM(message_tokens), 0) as total_input_tokens,
COALESCE(SUM(answer_tokens), 0) as total_output_tokens
FROM insurance_chat_records
WHERE {where_clause}
""")
total_result = db.session.execute(total_sql, bind_params).fetchone()
logging.info(f"[TOKEN_USAGE] 总量统计: total_tokens={total_result.total_tokens if total_result else 0}, input={total_result.total_input_tokens if total_result else 0}, output={total_result.total_output_tokens if total_result else 0}")
# 2. 按模型分组
model_sql = text(f"""
SELECT
COALESCE(NULLIF(model_id, ''), :default_model) as model,
COALESCE(SUM(CASE WHEN role = 'assistant' THEN answer_tokens ELSE message_tokens END), 0) as total_tokens,
COUNT(*) as message_count
FROM insurance_chat_records
WHERE {where_clause}
GROUP BY COALESCE(NULLIF(model_id, ''), :default_model)
ORDER BY total_tokens DESC
""")
model_result = db.session.execute(model_sql, bind_params).fetchall()
by_model = [row._asdict() for row in model_result]
logging.info(f"[TOKEN_USAGE] 按模型统计: {len(by_model)} 个模型, data={by_model[:3]}")
# 3. 按用户分组
user_sql = text(f"""
SELECT
user_id,
COALESCE(SUM(CASE WHEN role = 'assistant' THEN answer_tokens ELSE message_tokens END), 0) as total_tokens,
COUNT(*) as message_count
FROM insurance_chat_records
WHERE {where_clause}
GROUP BY user_id
ORDER BY total_tokens DESC
LIMIT 50
""")
user_result = db.session.execute(user_sql, bind_params).fetchall()
by_user = [row._asdict() for row in user_result]
logging.info(f"[TOKEN_USAGE] 按用户统计: {len(by_user)} 个用户, data={by_user[:3]}")
# 4. 日趋势最近30天
trend_sql = text(f"""
SELECT
DATE(created_at) as date,
COALESCE(SUM(CASE WHEN role = 'assistant' THEN answer_tokens ELSE message_tokens END), 0) as total_tokens
FROM insurance_chat_records
WHERE {where_clause}
GROUP BY DATE(created_at)
ORDER BY date DESC
LIMIT 30
""")
trend_result = db.session.execute(trend_sql, bind_params).fetchall()
daily_trend = [row._asdict() for row in trend_result]
return {
"code": 0,
"data": {
"total_tokens": total_result.total_tokens if total_result else 0,
"total_input_tokens": total_result.total_input_tokens if total_result else 0,
"total_output_tokens": total_result.total_output_tokens if total_result else 0,
"by_model": by_model,
"by_user": by_user,
"daily_trend": daily_trend,
},
}
except Exception as e:
import logging
logging.warning("get_token_usage 查询失败: %s", e)
return {
"code": 0,
"data": {
"total_tokens": 0,
"total_input_tokens": 0,
"total_output_tokens": 0,
"by_model": [],
"by_user": [],
"daily_trend": [],
},
}
def get_token_models(self) -> dict:
"""获取可用的模型列表。查询 insurance_chat_records 表。"""
try:
default_model = self._get_default_model_name()
sql = text("""
SELECT DISTINCT COALESCE(NULLIF(model_id, ''), :default_model) as model
FROM insurance_chat_records
ORDER BY model
""")
result = db.session.execute(sql, {"default_model": default_model}).fetchall()
models = [row[0] for row in result if row[0]]
return {"code": 0, "data": models}
except Exception:
return {"code": 0, "data": []}
def get_token_cost(self, start_date: str, end_date: str, group_by: str = "model") -> dict:
"""Token 成本统计(兼容旧接口)。"""
params = {
"start_date": start_date,
"end_date": end_date,
}
result = self.get_token_usage(params)
return result