feat(backend): AI 服务层 — 千问流式 LLM + 月报解析 + 健康度计算 + 风险检测 + Copilot

- LLM 客户端:全部 SSE 流式输出,兼容 OpenAI 接口
- AI 月报解析:SSE 流式端点 POST /reports/{id}/parse
- 健康度计算引擎:四维评分(财务/经营/AI商业化/AI成本)
- 风险自动检测引擎:6 条规则自动检测指标越界
- AI Copilot:SSE 流式对话 POST /copilot/chat
- 权限中间件:角色级 + 字段级权限控制
- 测试:21 个新测试(健康度 8 + 风险检测 8 + 权限 5),总计 64 passed
This commit is contained in:
selfrelease
2026-07-18 22:16:40 +08:00
parent 94be6189e9
commit 7ec4fb0747
15 changed files with 1244 additions and 52 deletions
+97
View File
@@ -0,0 +1,97 @@
"""月报 AI 解析服务。
使用千问 LLM 从月报原始文本中提取:
1. 结构化指标数据(营收、现金流、团队等)
2. AI 摘要
3. 关注点列表
"""
import logging
from typing import Any
from app.services.llm_client import llm_client
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """你是投后管理领域的专业分析师。请分析以下企业月报内容,提取结构化信息。
输出 JSON 格式如下:
{
"structured_data": {
"revenue": {"value": "", "unit": "万元", "yoy_change": "", "note": ""},
"cash_balance": {"value": "", "unit": "万元", "runway_months": 0, "note": ""},
"burn_rate": {"value": "", "unit": "万元/月", "trend": "up/stable/down", "note": ""},
"headcount": {"total": 0, "new_hires": 0, "departures": 0, "note": ""},
"key_metrics": [{"name": "", "value": "", "change": "", "note": ""}]
},
"ai_summary": "一段 100-200 字的月报摘要,概括企业经营状况和关键变化",
"ai_concerns": {
"items": [
{"category": "financial/operational/org/ai_specific", "severity": "low/medium/high", "description": ""}
],
"highlights": ["本期亮点1", "本期亮点2"]
}
}
注意:
- 如果月报内容不足以提取某项指标,对应字段留空或为 0
- severity 只能是 low/medium/high
- category 只能是 financial/operational/org/ai_specific
- 严格输出 JSON,不要包含其他文字"""
USER_PROMPT_TEMPLATE = """请分析以下 {period} 月报内容:
<<<USER_INPUT>>>
{content}
<<<END_USER_INPUT>>>
"""
async def parse_report(
content: str,
period_year: int,
period_month: int,
) -> dict[str, Any]:
"""解析月报内容,返回结构化数据。
Args:
content: 月报原始文本
period_year: 报告年份
period_month: 报告月份
Returns:
包含 structured_data / ai_summary / ai_concerns 的字典
Raises:
RuntimeError: LLM 调用失败
"""
if not content or not content.strip():
return {
"structured_data": {},
"ai_summary": "月报内容为空",
"ai_concerns": {"items": [], "highlights": []},
}
user_prompt = USER_PROMPT_TEMPLATE.format(
period=f"{period_year}{period_month}",
content=content[:4000], # 限制输入长度
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
try:
result = await llm_client.chat_json(messages, temperature=0.3, max_tokens=2000)
logger.info("月报 AI 解析成功: period=%s-%s", period_year, period_month)
return result
except RuntimeError as e:
logger.error("月报 AI 解析失败: %s", e)
# 降级:返回空结构
return {
"structured_data": {},
"ai_summary": f"AI 解析失败: {e}",
"ai_concerns": {"items": [], "highlights": []},
"fallback_used": True,
}
+228
View File
@@ -0,0 +1,228 @@
"""健康度评分计算引擎。
基于月报结构化数据,计算四维评分:
- 财务健康度(financial_score
- 经营健康度(operational_score
- AI 商业化度(ai_commercial_score
- AI 成本效率(ai_cost_score
总分 = 加权平均,输出 0-100 分。
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
# 权重配置
WEIGHTS = {
"financial": 0.35,
"operational": 0.25,
"ai_commercial": 0.25,
"ai_cost": 0.15,
}
def _safe_float(value: Any, default: float = 0.0) -> float:
"""安全转换为 float。"""
if value is None or value == "":
return default
try:
return float(value)
except (ValueError, TypeError):
return default
def _calc_financial_score(data: dict[str, Any]) -> float:
"""计算财务健康度。
指标:
- 现金跑道(runway_months):>12 月=90+6-12=60-90<6=<60
- 营收同比增长(yoy_change):正=加分,负=减分
- 烧钱率趋势(burn_rate trend):down=加分,up=减分
"""
score = 50.0 # 基础分
cash = data.get("cash_balance", {})
runway = _safe_float(cash.get("runway_months"))
if runway > 0:
if runway >= 12:
score += 30
elif runway >= 6:
score += 15
elif runway >= 3:
score -= 10
else:
score -= 30
revenue = data.get("revenue", {})
yoy = revenue.get("yoy_change", "")
if yoy:
yoy_val = _safe_float(str(yoy).replace("%", "").replace("+", ""))
if yoy_val > 0:
score += 15
elif yoy_val < 0:
score -= 15
burn = data.get("burn_rate", {})
trend = burn.get("trend", "")
if trend == "down":
score += 10
elif trend == "up":
score -= 10
return max(0, min(100, score))
def _calc_operational_score(data: dict[str, Any]) -> float:
"""计算经营健康度。
指标:
- 团队规模变化(headcount):净增长=加分
- 关键指标达成情况
"""
score = 60.0
headcount = data.get("headcount", {})
new_hires = _safe_float(headcount.get("new_hires"))
departures = _safe_float(headcount.get("departures"))
net_change = new_hires - departures
if net_change > 0:
score += 15
elif net_change < 0:
score -= 10
if departures > 5:
score -= 10 # 高流失率
key_metrics = data.get("key_metrics", [])
if key_metrics:
positive_count = sum(
1 for m in key_metrics
if _safe_float(str(m.get("change", "")).replace("%", "").replace("+", "")) > 0
)
score += (positive_count / len(key_metrics)) * 20
return max(0, min(100, score))
def _calc_ai_commercial_score(data: dict[str, Any]) -> float:
"""计算 AI 商业化度。
基于 key_metrics 中 AI 相关指标的达成情况。
"""
score = 50.0
key_metrics = data.get("key_metrics", [])
ai_metrics = [
m for m in key_metrics
if "ai" in str(m.get("name", "")).lower()
or "模型" in str(m.get("name", ""))
or "推理" in str(m.get("name", ""))
]
if ai_metrics:
for m in ai_metrics:
change = str(m.get("change", ""))
val = _safe_float(change.replace("%", "").replace("+", ""))
if val > 0:
score += 15
elif val < 0:
score -= 10
else:
# 无 AI 相关指标,给中等偏下分数
score = 40.0
return max(0, min(100, score))
def _calc_ai_cost_score(data: dict[str, Any]) -> float:
"""计算 AI 成本效率。
基于 burn_rate 和 AI 相关支出估算。
"""
score = 55.0
burn = data.get("burn_rate", {})
trend = burn.get("trend", "")
if trend == "down":
score += 20
elif trend == "up":
score -= 15
# 如果有 key_metrics 中的成本相关指标
key_metrics = data.get("key_metrics", [])
cost_metrics = [
m for m in key_metrics
if "成本" in str(m.get("name", "")) or "cost" in str(m.get("name", "")).lower()
]
for m in cost_metrics:
change = str(m.get("change", ""))
val = _safe_float(change.replace("%", "").replace("+", ""))
if val < 0: # 成本下降是好事
score += 10
elif val > 0:
score -= 10
return max(0, min(100, score))
def calculate_health_score(structured_data: dict[str, Any]) -> dict[str, float]:
"""计算四维健康度评分。
Args:
structured_data: 月报 AI 解析后的结构化数据
Returns:
包含 total_score 和四个维度分数的字典
"""
if not structured_data:
return {
"total_score": 0.0,
"financial_score": 0.0,
"operational_score": 0.0,
"ai_commercial_score": 0.0,
"ai_cost_score": 0.0,
}
financial = _calc_financial_score(structured_data)
operational = _calc_operational_score(structured_data)
ai_commercial = _calc_ai_commercial_score(structured_data)
ai_cost = _calc_ai_cost_score(structured_data)
total = (
financial * WEIGHTS["financial"]
+ operational * WEIGHTS["operational"]
+ ai_commercial * WEIGHTS["ai_commercial"]
+ ai_cost * WEIGHTS["ai_cost"]
)
result = {
"total_score": round(total, 1),
"financial_score": round(financial, 1),
"operational_score": round(operational, 1),
"ai_commercial_score": round(ai_commercial, 1),
"ai_cost_score": round(ai_cost, 1),
}
logger.info("健康度评分计算完成: %s", result)
return result
def determine_trend(current_score: float, previous_score: float | None) -> str:
"""判断评分趋势。
Args:
current_score: 当前评分
previous_score: 上期评分(如有)
Returns:
"up" / "down" / "stable"
"""
if previous_score is None:
return "stable"
diff = current_score - previous_score
if diff > 5:
return "up"
elif diff < -5:
return "down"
return "stable"
+179
View File
@@ -0,0 +1,179 @@
"""LLM 客户端 — 千问 DashScope OpenAI 兼容模式。
提供统一的 LLM 调用接口,全部使用流式输出(SSE)。
"""
import json
import logging
from collections.abc import AsyncGenerator
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
class LLMClient:
"""千问 LLM 客户端(OpenAI 兼容接口,流式输出)。"""
def __init__(
self,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
timeout: int | None = None,
):
self.api_key = api_key or settings.llm_api_key
self.base_url = base_url or settings.llm_base_url
self.model = model or settings.llm_model
self.timeout = timeout or settings.llm_timeout_seconds
def _build_headers(self) -> dict[str, str]:
"""构建请求头。"""
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
def _build_payload(
self,
messages: list[dict[str, str]],
temperature: float,
max_tokens: int,
) -> dict[str, Any]:
"""构建请求体。"""
return {
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
}
async def chat_stream(
self,
messages: list[dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 2000,
) -> AsyncGenerator[str, None]:
"""流式 chat completion,逐 token yield。
Args:
messages: OpenAI 格式的消息列表
temperature: 温度参数
max_tokens: 最大 token 数
Yields:
每个 token 的文本片段
Raises:
RuntimeError: API 调用失败
"""
if not self.api_key:
raise RuntimeError("LLM_API_KEY 未配置")
headers = self._build_headers()
payload = self._build_payload(messages, temperature, max_tokens)
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
async with client.stream(
"POST",
f"{self.base_url}/chat/completions",
headers=headers,
json=payload,
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data_str = line[6:]
if data_str.strip() == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except json.JSONDecodeError:
continue
except httpx.TimeoutException:
logger.error("LLM 流式请求超时")
raise RuntimeError("LLM 请求超时")
except httpx.HTTPStatusError as e:
logger.error("LLM API 错误: %s", e.response.status_code)
raise RuntimeError(f"LLM API 错误: {e.response.status_code}")
except RuntimeError:
raise
except Exception as e:
logger.error("LLM 流式调用异常: %s", e)
raise RuntimeError(f"LLM 调用失败: {e}")
async def chat(
self,
messages: list[dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 2000,
) -> str:
"""流式调用但收集为完整字符串(兼容非流式调用方)。"""
parts: list[str] = []
async for token in self.chat_stream(messages, temperature, max_tokens):
parts.append(token)
return "".join(parts)
async def chat_json_stream(
self,
messages: list[dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 2000,
) -> AsyncGenerator[str, None]:
"""流式 JSON 输出,逐 token yield 原始文本片段。
调用方自行收集并解析 JSON。
"""
# 确保 system prompt 要求 JSON 输出
messages = list(messages)
if messages and messages[0]["role"] == "system":
if "json" not in messages[0]["content"].lower():
messages[0]["content"] += "\n\n请严格以 JSON 格式输出,不要包含 markdown 代码块标记。"
else:
messages.insert(0, {
"role": "system",
"content": "你是一个专业的投后管理分析助手。请严格以 JSON 格式输出,不要包含 markdown 代码块标记。",
})
async for token in self.chat_stream(messages, temperature, max_tokens):
yield token
async def chat_json(
self,
messages: list[dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 2000,
) -> dict[str, Any]:
"""流式调用但收集为完整 JSON 对象(兼容非流式调用方)。"""
parts: list[str] = []
async for token in self.chat_json_stream(messages, temperature, max_tokens):
parts.append(token)
text = "".join(parts)
# 清理可能的 markdown 代码块
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError as e:
logger.error("LLM JSON 解析失败: %s, 原始文本: %s", e, text[:500])
raise RuntimeError(f"LLM 输出 JSON 解析失败: {e}")
llm_client = LLMClient()
+160
View File
@@ -0,0 +1,160 @@
"""风险自动检测引擎。
基于月报结构化数据,检测指标越界并自动生成风险事件。
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
# 风险检测规则
RULES = [
{
"name": "现金跑道不足",
"type": "financial",
"severity": "critical",
"condition": lambda d: _get_runway(d) < 3 and _get_runway(d) > 0,
"title": "现金跑道不足 3 个月",
"description": "当前现金跑道仅 {runway} 个月,需紧急融资",
"suggested_action": "立即启动融资对话,评估 bridge loan 可能性",
},
{
"name": "现金跑道预警",
"type": "financial",
"severity": "high",
"condition": lambda d: 3 <= _get_runway(d) < 6,
"title": "现金跑道低于 6 个月",
"description": "当前现金跑道 {runway} 个月,需加快融资进度",
"suggested_action": "与创始人沟通融资时间表,准备备选方案",
},
{
"name": "烧钱率上升",
"type": "financial",
"severity": "medium",
"condition": lambda d: _get_burn_trend(d) == "up",
"title": "烧钱率持续上升",
"description": "月度烧钱率呈上升趋势,需关注成本控制",
"suggested_action": "审查主要支出项,制定成本优化计划",
},
{
"name": "营收下滑",
"type": "financial",
"severity": "high",
"condition": lambda d: _get_yoy(revenue=d) < 0,
"title": "营收同比下滑",
"description": "营收同比下降 {yoy}%,需关注业务增长",
"suggested_action": "分析营收下滑原因,调整商业策略",
},
{
"name": "高人员流失",
"type": "org",
"severity": "medium",
"condition": lambda d: _get_departures(d) > 5,
"title": "人员流失率较高",
"description": "本月离职 {departures} 人,需关注团队稳定性",
"suggested_action": "了解离职原因,评估核心岗位风险",
},
{
"name": "团队净缩减",
"type": "org",
"severity": "high",
"condition": lambda d: _get_net_headcount(d) < -3,
"title": "团队规模显著缩减",
"description": "本月团队净减少 {net} 人,需关注组织健康",
"suggested_action": "与创始人沟通团队规划,评估关键岗位覆盖",
},
]
def _get_runway(data: dict[str, Any]) -> float:
"""获取现金跑道月数。"""
cash = data.get("cash_balance", {})
try:
return float(cash.get("runway_months", 0) or 0)
except (ValueError, TypeError):
return 0.0
def _get_burn_trend(data: dict[str, Any]) -> str:
"""获取烧钱率趋势。"""
burn = data.get("burn_rate", {})
return burn.get("trend", "")
def _get_yoy(revenue: dict[str, Any], d: dict[str, Any] = None) -> float:
"""获取营收同比增长率。"""
if d is None:
d = revenue
revenue = d.get("revenue", {})
yoy = revenue.get("yoy_change", "")
try:
return float(str(yoy).replace("%", "").replace("+", "") or 0)
except (ValueError, TypeError):
return 0.0
def _get_departures(data: dict[str, Any]) -> float:
"""获取离职人数。"""
hc = data.get("headcount", {})
try:
return float(hc.get("departures", 0) or 0)
except (ValueError, TypeError):
return 0.0
def _get_net_headcount(data: dict[str, Any]) -> float:
"""获取团队净变化。"""
hc = data.get("headcount", {})
try:
new = float(hc.get("new_hires", 0) or 0)
dep = float(hc.get("departures", 0) or 0)
return new - dep
except (ValueError, TypeError):
return 0.0
def detect_risks(
structured_data: dict[str, Any],
company_id: str,
) -> list[dict[str, Any]]:
"""从月报结构化数据中检测风险事件。
Args:
structured_data: 月报 AI 解析后的结构化数据
company_id: 企业 ID
Returns:
风险事件列表,每项包含 type/severity/title/description/suggested_action
"""
if not structured_data:
return []
risks: list[dict[str, Any]] = []
runway = _get_runway(structured_data)
yoy = _get_yoy(structured_data)
departures = _get_departures(structured_data)
net_hc = _get_net_headcount(structured_data)
for rule in RULES:
try:
if rule["condition"](structured_data):
risk = {
"company_id": company_id,
"type": rule["type"],
"severity": rule["severity"],
"title": rule["title"],
"description": rule["description"].format(
runway=runway,
yoy=abs(yoy),
departures=departures,
net=abs(net_hc),
),
"suggested_action": rule["suggested_action"],
}
risks.append(risk)
logger.info("检测到风险: %s%s", rule["name"], risk["title"])
except Exception as e:
logger.warning("风险检测规则 '%s' 执行异常: %s", rule["name"], e)
return risks