Files
company-ai-platform/app/modules/feishu/commands.py
JiuContinent 9cf7c44393 ```
feat: 添加生命周期报告和AI规则管理功能

- 在Dockerfile中添加pillow依赖包用于图像处理
- 实现生命周期报告调度任务,支持日报和周报两种类型
- 新增TASK_RUN_LIFECYCLE任务常量和相关配置选项
- 扩展AI Agent服务以支持用户规则,并在分析时应用规则
- 添加AI用户规则创建、更新和查询接口
- 增加项目生命周期和财务需求分析技能
- 扩展现有模型以支持更完整的业务数据字段
- 实现飞书图片上传功能用于报告展示
```
2026-07-12 17:44:49 +08:00

526 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

import json
import re
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.config import get_settings
from app.modules.ai_agent.service import AIService
from app.modules.ai_agent.constants import AIResponseKey
from app.modules.audit.constants import AuditSource
from app.modules.ai_memory.constants import AIMemoryStatus
from app.modules.ai_memory.service import AIMemoryService
from app.modules.feishu.constants import (
FEISHU_AI_REPLY_TITLE,
FEISHU_MENTION_PATTERN,
FEISHU_ZERO_WIDTH_SPACE,
FeishuCommandKey,
FeishuCommandName,
FeishuCommandResultKey,
FeishuPayloadKey,
FeishuReplyType,
)
from app.modules.reports.constants import ReportResponseKey
from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart
from app.modules.feishu.service import FeishuService
from app.modules.reports.services import ReportService
from app.modules.risk.constants import RiskSummaryKey
from app.modules.risk.services import RiskService
DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报")
PROJECT_WEEKLY_KEYWORDS = ("周报", "项目周报")
ATTENDANCE_KEYWORDS = ("打卡", "考勤", "attendance")
RISK_KEYWORDS = ("风险", "预警", "risk")
AI_COMMAND_PREFIXES = ("", "ai ", "AI ", "/ask ")
RISK_TITLE = "风险预警"
DEFAULT_AI_PROMPT = "请说明你能做什么。"
RULE_TITLE = "AI 学习规则"
RULE_CREATE_PATTERN = re.compile(r"^学习规则(?:\s+(\d{1,3}))?\s*[:]\s*(.*)$")
RULE_DISABLE_PATTERN = re.compile(r"^停用规则\s+(MEM-[A-Za-z0-9-]+)$", re.IGNORECASE)
RULE_ENABLE_PATTERN = re.compile(r"^启用规则\s+(MEM-[A-Za-z0-9-]+)$", re.IGNORECASE)
RULE_LIST_COMMANDS = {"查看规则", "规则列表"}
RULE_COMMAND_PREFIXES = ("学习规则", "查看规则", "规则列表", "停用规则", "启用规则")
RULE_COMMAND_HELP = (
"规则指令格式:\n"
"学习规则:<规则内容>\n"
"学习规则 80<规则内容>\n"
"查看规则\n"
"停用规则 <规则编号>\n"
"启用规则 <规则编号>"
)
PROJECT_FINANCE_PATTERN = re.compile(r"^项目资金\s+(.+)$")
FINANCE_COMMANDS = {"资金需求", "未来30天资金需求"}
def _parse_content_text(content: Any) -> str:
"""Extract plain command text from a Feishu message content payload."""
if isinstance(content, dict):
return str(
content.get(FeishuPayloadKey.TEXT)
or content.get(FeishuPayloadKey.CONTENT)
or ""
)
if not isinstance(content, str):
return ""
try:
data = json.loads(content)
except json.JSONDecodeError:
return content
if isinstance(data, dict):
return str(
data.get(FeishuPayloadKey.TEXT)
or data.get(FeishuPayloadKey.CONTENT)
or ""
)
return content
def _clean_command_text(text: str) -> str:
"""Remove mentions and invisible characters from Feishu command text."""
text = re.sub(FEISHU_MENTION_PATTERN, "", text or "")
text = text.replace(FEISHU_ZERO_WIDTH_SPACE, "")
return text.strip()
def _command_result(
command: FeishuCommandName,
reply_type: FeishuReplyType,
title: str,
content: str,
provider_response: dict[str, Any] | None = None,
lines: list[str] | None = None,
) -> dict[str, Any]:
result: dict[str, Any] = {
FeishuCommandResultKey.COMMAND: command,
FeishuCommandResultKey.REPLY_TYPE: reply_type,
FeishuCommandResultKey.TITLE: title,
FeishuCommandResultKey.CONTENT: content,
FeishuCommandResultKey.PROVIDER_RESPONSE: provider_response,
}
if lines is not None:
result[FeishuCommandResultKey.LINES] = lines
return result
class FeishuCommandService:
"""Route Feishu text commands to reports, risk summaries, or AI replies."""
def __init__(self, db: Session):
self.db = db
self.feishu = FeishuService(db)
def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None:
event = payload.get(FeishuPayloadKey.EVENT) or {}
message = event.get(FeishuPayloadKey.MESSAGE) or {}
if not message:
return None
text = _clean_command_text(_parse_content_text(message.get(FeishuPayloadKey.CONTENT)))
if not text:
return None
sender = event.get(FeishuPayloadKey.SENDER) or {}
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
actor = (
sender_id.get(FeishuPayloadKey.OPEN_ID)
or sender_id.get(FeishuPayloadKey.USER_ID)
or ActorValue.FEISHU
)
return {
FeishuCommandKey.TEXT: text,
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID),
FeishuCommandKey.ACTOR: actor,
}
def handle_text(
self,
text: str,
chat_id: str | None = None,
actor: str = ActorValue.FEISHU,
auto_reply: bool = True,
) -> dict[str, Any]:
command_text = _clean_command_text(text)
lowered = command_text.lower()
provider_response: dict[str, Any] | None = None
rule_result = self._handle_rule_command(
command_text,
chat_id=chat_id,
actor=actor,
auto_reply=auto_reply,
)
if rule_result is not None:
return rule_result
finance_result = self._handle_finance_command(
command_text,
chat_id=chat_id,
actor=actor,
auto_reply=auto_reply,
)
if finance_result is not None:
return finance_result
if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS):
report = ReportService(self.db).daily_brief()
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
actor,
)
return _command_result(
FeishuCommandName.DAILY_BRIEF,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
if any(keyword in command_text for keyword in PROJECT_WEEKLY_KEYWORDS):
report = ReportService(self.db).project_weekly()
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
actor,
)
return _command_result(
FeishuCommandName.PROJECT_WEEKLY,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
if any(keyword in command_text for keyword in ATTENDANCE_KEYWORDS):
report = ReportService(self.db).attendance_summary()
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
actor,
)
return _command_result(
FeishuCommandName.ATTENDANCE_SUMMARY,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
if any(keyword in command_text for keyword in RISK_KEYWORDS):
summary = RiskService(self.db).summary()
lines = [
f"- 综合风险等级:{summary[RiskSummaryKey.RISK_LEVEL]}",
f"- 风险分:{summary[RiskSummaryKey.RISK_SCORE]}",
f"- 逾期任务:{len(summary[RiskSummaryKey.OVERDUE_TASKS])}",
f"- 延期项目:{len(summary[RiskSummaryKey.DELAYED_PROJECTS])}",
f"- 超预算项目:{len(summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}",
f"- 资金风险账户:{len(summary[RiskSummaryKey.FUND_RISKS])}",
f"- 供应商风险:{len(summary[RiskSummaryKey.SUPPLIER_RISKS])}",
f"- 打开风险事件:{len(summary[RiskSummaryKey.OPEN_EVENTS])}",
]
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
RISK_TITLE,
lines,
actor,
)
return _command_result(
FeishuCommandName.RISK_SUMMARY,
FeishuReplyType.CARD,
RISK_TITLE,
"\n".join(lines),
provider_response,
lines,
)
prompt = command_text
for prefix in AI_COMMAND_PREFIXES:
if command_text.startswith(prefix):
prompt = command_text[len(prefix) :].strip()
break
if not prompt:
prompt = DEFAULT_AI_PROMPT
ai_result = AIService(self.db).ask(
prompt,
context={},
actor=actor,
source=AuditSource.FEISHU,
)
content = ai_result[AIResponseKey.ANSWER]
is_explicit_ai = any(
command_text.startswith(prefix) or lowered.startswith(prefix)
for prefix in AI_COMMAND_PREFIXES
)
if auto_reply:
provider_response = self._send_text_if_configured(chat_id, content, actor)
return _command_result(
FeishuCommandName.AI_ASK if is_explicit_ai else FeishuCommandName.FALLBACK_AI,
FeishuReplyType.TEXT,
FEISHU_AI_REPLY_TITLE,
content,
provider_response,
)
def _handle_finance_command(
self,
command_text: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any] | None:
project_match = PROJECT_FINANCE_PATTERN.fullmatch(command_text)
if command_text not in FINANCE_COMMANDS and project_match is None:
return None
command = (
FeishuCommandName.PROJECT_FINANCE
if project_match
else FeishuCommandName.FINANCE_NEEDS
)
if not get_settings().finance_needs_enabled:
content = "项目资金需求分析尚未启用,请先配置并启用财务只读同步。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"项目资金需求分析",
content,
provider_response,
)
project_code = project_match.group(1).strip() if project_match else None
service = ReportService(self.db)
preview = service.project_finance_needs_report(
project_code=project_code,
include_ai=False,
actor=actor,
)
if project_code and not preview["items"]:
content = f"未找到项目“{project_code}”,请使用稳定项目编号或展示编号。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"项目资金需求分析",
content,
provider_response,
)
if not preview["summary"]["data_available"]:
content = "项目财务数据未接入或无有效记录,暂不生成资金分析报告。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"项目资金需求分析",
content,
provider_response,
)
report = service.project_finance_needs_report(
project_code=project_code,
include_ai=True,
actor=actor,
)
ai_analysis = report.get("ai_analysis") or {}
if not ai_analysis.get(AIResponseKey.OK):
content = "AI 当前不可用,本次项目资金分析报告未发送。请检查模型服务。"
provider_response = (
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
)
return _command_result(
command,
FeishuReplyType.TEXT,
"AI 暂不可用",
content,
provider_response,
)
provider_response = None
if auto_reply:
provider_response = self._send_finance_card_if_configured(chat_id, report, actor)
return _command_result(
command,
FeishuReplyType.CARD,
report[ReportResponseKey.TITLE],
report[ReportResponseKey.CONTENT],
provider_response,
report[ReportResponseKey.LINES],
)
def _send_finance_card_if_configured(
self,
chat_id: str | None,
report: dict[str, Any],
actor: str,
) -> dict[str, Any] | None:
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
chart_data = {
"period": report.get("as_of"),
"finance": report.get("finance_chart_data"),
}
image_result = self.feishu.upload_image(render_lifecycle_chart(chart_data), actor)
image_key = (image_result.get("data") or {}).get("image_key")
if not image_key:
raise ValueError("Feishu image upload did not return image_key")
card = FeishuService.build_basic_card(
report[ReportResponseKey.TITLE],
report[ReportResponseKey.LINES],
image_key=image_key,
image_alt=lifecycle_chart_alt(chart_data),
)
return self.feishu.send_card(card, receive_id=chat_id, actor=actor)
def _handle_rule_command(
self,
command_text: str,
chat_id: str | None,
actor: str,
auto_reply: bool,
) -> dict[str, Any] | None:
if not command_text.startswith(RULE_COMMAND_PREFIXES):
return None
if command_text.startswith("停用规则"):
command = FeishuCommandName.RULE_DISABLE
elif command_text.startswith("启用规则"):
command = FeishuCommandName.RULE_ENABLE
elif command_text.startswith(("查看规则", "规则列表")):
command = FeishuCommandName.RULE_LIST
else:
command = FeishuCommandName.RULE_CREATE
content = RULE_COMMAND_HELP
try:
create_match = RULE_CREATE_PATTERN.fullmatch(command_text)
disable_match = RULE_DISABLE_PATTERN.fullmatch(command_text)
enable_match = RULE_ENABLE_PATTERN.fullmatch(command_text)
memory = AIMemoryService(self.db)
if create_match:
priority = int(create_match.group(1) or 50)
rule_content = create_match.group(2).strip()
if not rule_content:
content = f"规则内容不能为空。\n\n{RULE_COMMAND_HELP}"
elif not 1 <= priority <= 100:
content = "规则优先级必须在 1 到 100 之间。"
else:
rule = memory.create_rule(
content=rule_content,
scope="global",
subject="company",
priority=priority,
tags=["feishu"],
actor=actor,
)
content = (
"规则已学习。\n"
f"编号:{rule['code']}\n"
f"优先级:{rule['importance']}\n"
f"范围:{rule['scope']} / {rule['subject']}\n"
"状态:已启用"
)
elif command_text in RULE_LIST_COMMANDS:
command = FeishuCommandName.RULE_LIST
rules = memory.list_rules(
status_filter=AIMemoryStatus.ACTIVE,
limit=20,
)
if not rules:
content = "当前没有已启用的学习规则。"
else:
lines = ["当前已启用的学习规则:"]
for rule in rules:
rule_text = str(rule["content"])
if len(rule_text) > 80:
rule_text = f"{rule_text[:80]}"
lines.append(
f"{rule['code']}|优先级 {rule['importance']}"
f"{rule['scope']}/{rule['subject']}\n{rule_text}"
)
content = "\n\n".join(lines)
elif disable_match or enable_match:
enabled = enable_match is not None
command = (
FeishuCommandName.RULE_ENABLE
if enabled
else FeishuCommandName.RULE_DISABLE
)
match = enable_match or disable_match
rule = memory.update_rule(
code=match.group(1),
content=None,
priority=None,
tags=None,
enabled=enabled,
actor=actor,
)
state = "已启用" if enabled else "已停用"
content = (
f"规则{state}\n"
f"编号:{rule['code']}\n"
f"优先级:{rule['importance']}\n"
f"范围:{rule['scope']} / {rule['subject']}\n"
f"状态:{state}"
)
except HTTPException as exc:
detail = str(exc.detail)
if "secret-like" in detail:
content = "规则疑似包含密码、令牌或其他密钥信息,已拒绝学习。"
elif exc.status_code == 404:
content = "没有找到该规则,请先发送“查看规则”确认规则编号。"
elif "priority" in detail:
content = "规则优先级必须在 1 到 100 之间。"
else:
content = "规则未保存,请检查指令内容后重试。"
provider_response = None
if auto_reply:
provider_response = self._send_text_if_configured(chat_id, content, actor)
return _command_result(
command,
FeishuReplyType.TEXT,
RULE_TITLE,
content,
provider_response,
)
def _send_card_if_configured(
self,
chat_id: str | None,
title: str,
lines: list[str],
actor: str,
) -> dict[str, Any] | None:
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
card = FeishuService.build_basic_card(title, lines)
return self.feishu.send_card(card, receive_id=chat_id, actor=actor)
def _send_text_if_configured(
self,
chat_id: str | None,
text: str,
actor: str,
) -> dict[str, Any] | None:
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
return self.feishu.send_text(text, receive_id=chat_id, actor=actor)