feat(r1-touch): 建议分级+决策类推送+应用前预览 (alert不推送/同title防轰炸/preview对比)

This commit is contained in:
Hermes CI Fix
2026-08-31 09:07:54 +08:00
parent df93b635b3
commit 076bd0dae0
7 changed files with 429 additions and 1 deletions
+46 -1
View File
@@ -8,7 +8,7 @@ from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan, BudgetPlan, AISuggestion
from app.utils.cache import get as cache_get, set as cache_set
import json, hashlib, httpx, os
import json, hashlib, httpx, os, urllib.request
from datetime import datetime, date
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
@@ -25,6 +25,8 @@ def _sug_dict(s: AISuggestion) -> dict:
"source": s.source,
"suggestion_type": s.suggestion_type,
"target_type": s.target_type,
"category": s.category or "decision",
"pushed": s.pushed or 0,
"target_id": s.target_id,
"title": s.title,
"content": s.content,
@@ -76,6 +78,7 @@ def generate_rule_suggestions(db: Session, entity_id: int,
source=source,
suggestion_type=suggestion_type,
target_type=target_type,
category="alert" if target_type == "alert" else "decision",
target_id=tid,
title=title,
content=content,
@@ -170,9 +173,51 @@ def generate_rule_suggestions(db: Session, entity_id: int,
db.commit()
for s in created:
db.refresh(s)
# R1触达修复(2026-08-31): 只对新建的决策类建议推送企微(预警类不推防噪音)
# 防轰炸: 同 title 建议幂等不重建 + pushed 标记只推一次;存量不推(只推新建)
for s in created:
if s.category == "decision" and not s.pushed:
ok = _push_decision_suggestion(s)
if ok:
s.pushed = 1
db.commit()
return created
_TYPE_LABELS = {"kpi_target": "KPI目标", "budget_adjust": "预算调整", "action_plan": "行动方案"}
def _push_decision_suggestion(s: AISuggestion) -> bool:
"""决策类建议推送到企微(8800 relay,与 lead.py 同款已验证)
仅 decision 类;预警类不进推送流。失败不影响主流程(try/except)。
"""
if getattr(s, "category", "decision") != "decision":
return False
type_label = _TYPE_LABELS.get(s.suggestion_type, s.suggestion_type)
content = (
f"## 📌 AI决策建议\n"
f"**{s.title}**\n"
f"{str(s.content or '')[:120]}\n"
f"类型标签: {type_label}\n"
f"---\n"
f"{datetime.now().strftime('%Y-%m-%d %H:%M')}"
)
msg = {"msgtype": "markdown", "markdown": {"content": content}}
try:
data = json.dumps(msg, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
"http://127.0.0.1:8800/send",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req, timeout=5)
return True
except Exception:
return False
def _unapplied_suggestions(db: Session, entity_id: int, limit: int = 20) -> list:
items = db.query(AISuggestion).filter(
AISuggestion.entity_id == entity_id,
+60
View File
@@ -27,6 +27,8 @@ def _sug_dict(s: AISuggestion) -> dict:
"source": s.source,
"suggestion_type": s.suggestion_type,
"target_type": s.target_type,
"category": s.category or "decision",
"pushed": s.pushed or 0,
"target_id": s.target_id,
"title": s.title,
"content": s.content,
@@ -62,6 +64,7 @@ def create_suggestion(
source=data.get("source", "manual"),
suggestion_type=suggestion_type,
target_type=data.get("target_type", "kpi"),
category="alert" if data.get("target_type") == "alert" else data.get("category", "decision"),
target_id=data.get("target_id"),
title=title,
content=data.get("content"),
@@ -78,6 +81,7 @@ def create_suggestion(
def list_suggestions(
status: Optional[str] = Query(None, description="unapplied/applied/dismissed"),
suggestion_type: Optional[str] = Query(None),
category: Optional[str] = Query(None, description="decision/alert 建议分类过滤"),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
@@ -87,6 +91,8 @@ def list_suggestions(
query = query.filter(AISuggestion.status == status)
if suggestion_type:
query = query.filter(AISuggestion.suggestion_type == suggestion_type)
if category:
query = query.filter(AISuggestion.category == category)
items = query.order_by(AISuggestion.created_at.desc()).limit(200).all()
return {"data": [_sug_dict(s) for s in items], "total": len(items)}
@@ -275,6 +281,60 @@ _APPLYERS = {
}
@router.get("/{suggestion_id}/preview")
def preview_suggestion(suggestion_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""应用前预览:将变更什么(当前值 → 新值),建立信任 (R1触达修复 2026-08-31)
- kpi_target: {kpi_name, current_target, new_target}
- budget_adjust:{kpi_name, period, current_budget, new_budget}
- action_plan: {kpi_name, plan_title, assignee, priority, due_date}
"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
sd = sug.suggestion_data or {}
kpi = None
kpi_id = sd.get("kpi_id") or sug.target_id
if kpi_id:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if sug.suggestion_type == "kpi_target":
return {"data": {
"type": "kpi_target",
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
"current_target": kpi.target_value if kpi else None,
"new_target": sd.get("target_value"),
}}
if sug.suggestion_type == "budget_adjust":
period = sd.get("period") or sug.target_type
current_budget = None
if kpi and period:
bp = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == sug.entity_id,
BudgetPlan.kpi_id == kpi.id,
BudgetPlan.period == period,
BudgetPlan.status == "active",
).order_by(BudgetPlan.id.desc()).first()
current_budget = bp.budget_value if bp else None
return {"data": {
"type": "budget_adjust",
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
"period": period,
"current_budget": current_budget,
"new_budget": sd.get("budget_value"),
}}
# action_plan
return {"data": {
"type": "action_plan",
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
"plan_title": sd.get("title") or sug.title,
"assignee": sd.get("assignee") or "",
"priority": sd.get("priority") or "medium",
"due_date": sd.get("due_date") or "",
}}
@router.post("/{suggestion_id}/apply")
def apply_suggestion(
suggestion_id: int,
+2
View File
@@ -922,6 +922,8 @@ class AISuggestion(Base):
source = Column(String(30), default="dashboard", comment="来源: dashboard/kpi/budget/manual/rule")
suggestion_type = Column(String(30), nullable=False, comment="kpi_target/budget_adjust/action_plan")
target_type = Column(String(30), nullable=False, comment="kpi/budget/action_plan")
category = Column(String(20), default="decision", comment="分类: decision决策类 / alert预警类(预警类不推送)")
pushed = Column(Integer, default=0, comment="决策类建议是否已推送企微 0/1(防轰炸)")
target_id = Column(Integer, nullable=True, comment="目标ID (KPI ID/预算KPI ID等)")
title = Column(String(300), nullable=False, comment="建议标题")
content = Column(Text, nullable=True, comment="建议内容/理由")