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:
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user