feat: 添加生命周期报告和AI规则管理功能

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

View File

@@ -2,6 +2,7 @@ import json
import re
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
@@ -9,6 +10,8 @@ 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,
@@ -20,6 +23,7 @@ from app.modules.feishu.constants import (
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
@@ -32,6 +36,22 @@ 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:
@@ -125,6 +145,24 @@ class FeishuCommandService:
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:
@@ -235,6 +273,233 @@ class FeishuCommandService:
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,