feat(scheduler): 新增多种报表推送功能并重构调度器代码 - 新增考勤汇总、风险进度、工作日报、工作周报等报表推送功能 - 重构调度器中的报表推送逻辑,提取通用的 deliver_report 函数 - 添加新的定时任务配置项用于各种报表推送 - 实现飞书推送配置检查函数 _feishu_delivery_configured - 将重复的报表推送代码抽取为可复用的函数模式 ```
733 lines
29 KiB
Python
733 lines
29 KiB
Python
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.feishu.service import FeishuService
|
||
from app.modules.market.chart import render_market_chart
|
||
from app.modules.market.service import MarketService
|
||
from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart
|
||
from app.modules.reports.constants import ReportResponseKey
|
||
from app.modules.reports.services import ReportService
|
||
|
||
DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报")
|
||
PROJECT_WEEKLY_KEYWORDS = ("周报", "项目周报")
|
||
ATTENDANCE_KEYWORDS = ("打卡", "考勤", "attendance")
|
||
RISK_KEYWORDS = ("风险", "预警", "risk")
|
||
AI_COMMAND_PREFIXES = ("问 ", "ai ", "AI ", "/ask ")
|
||
DEFAULT_AI_PROMPT = "请说明你能做什么。"
|
||
RULE_TITLE = "AI 学习规则"
|
||
RULE_CREATE_PATTERN = re.compile(r"^学习规则(?:\s+(\d{1,3}))?\s*[::]\s*(.*)$")
|
||
MARKET_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"
|
||
"学习市场规则 80:<仅用于市场分析的规则内容>\n"
|
||
"查看规则\n"
|
||
"停用规则 <规则编号>\n"
|
||
"启用规则 <规则编号>"
|
||
)
|
||
PROJECT_FINANCE_PATTERN = re.compile(r"^项目资金\s+(.+)$")
|
||
FINANCE_COMMANDS = {"资金需求", "未来30天资金需求"}
|
||
STOCK_ANALYSIS_PATTERN = re.compile(
|
||
r"^(?:股票分析|估值分析|财报分析)\s+([0-9]{6}(?:\.(?:SH|SZ|BJ))?)$", re.IGNORECASE
|
||
)
|
||
WATCHLIST_ADD_PATTERN = re.compile(r"^加入自选\s+([0-9]{6}(?:\.(?:SH|SZ|BJ))?)$", re.IGNORECASE)
|
||
MARKET_COMMANDS = {
|
||
"市场分析",
|
||
"今日收盘分析",
|
||
"本周市场分析",
|
||
"宏观金融分析",
|
||
"最新公告",
|
||
}
|
||
INDUSTRY_ANALYSIS_PATTERN = re.compile(r"^行业分析\s+(.+)$")
|
||
STOCK_COMPARE_PATTERN = re.compile(
|
||
r"^股票对比\s+([0-9]{6}(?:\.(?:SH|SZ|BJ))?)\s+" r"([0-9]{6}(?:\.(?:SH|SZ|BJ))?)$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
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
|
||
|
||
market_result = self._handle_market_command(command_text, chat_id, actor, auto_reply)
|
||
if market_result is not None:
|
||
return market_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):
|
||
report = ReportService(self.db).risk_progress()
|
||
if auto_reply:
|
||
provider_response = self._send_card_if_configured(
|
||
chat_id,
|
||
report[ReportResponseKey.TITLE],
|
||
report[ReportResponseKey.LINES],
|
||
actor,
|
||
)
|
||
return _command_result(
|
||
FeishuCommandName.RISK_SUMMARY,
|
||
FeishuReplyType.CARD,
|
||
report[ReportResponseKey.TITLE],
|
||
report[ReportResponseKey.CONTENT],
|
||
provider_response,
|
||
report[ReportResponseKey.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 _handle_market_command(
|
||
self, text: str, chat_id: str | None, actor: str, auto_reply: bool
|
||
) -> dict[str, Any] | None:
|
||
stock = STOCK_ANALYSIS_PATTERN.fullmatch(text)
|
||
add = WATCHLIST_ADD_PATTERN.fullmatch(text)
|
||
industry = INDUSTRY_ANALYSIS_PATTERN.fullmatch(text)
|
||
comparison = STOCK_COMPARE_PATTERN.fullmatch(text)
|
||
if (
|
||
text not in MARKET_COMMANDS
|
||
and text != "查看自选"
|
||
and not stock
|
||
and not add
|
||
and not industry
|
||
and not comparison
|
||
):
|
||
return None
|
||
if not get_settings().market_analysis_enabled:
|
||
content = "市场分析尚未启用,请配置市场数据源后启用。"
|
||
response = (
|
||
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
|
||
)
|
||
return _command_result(
|
||
FeishuCommandName.MARKET_OVERVIEW,
|
||
FeishuReplyType.TEXT,
|
||
"市场分析",
|
||
content,
|
||
response,
|
||
)
|
||
service = MarketService(self.db)
|
||
if add:
|
||
if get_settings().read_only_mode:
|
||
content = "当前为只读模式,不能修改自选股。请由管理员启用操作后重试。"
|
||
response = (
|
||
self._send_text_if_configured(chat_id, content, actor)
|
||
if auto_reply
|
||
else None
|
||
)
|
||
return _command_result(
|
||
FeishuCommandName.WATCHLIST_ADD,
|
||
FeishuReplyType.TEXT,
|
||
"自选股",
|
||
content,
|
||
response,
|
||
)
|
||
item = service.add_watchlist(actor, add.group(1))
|
||
content = f"已加入自选:{item['symbol']}"
|
||
response = (
|
||
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
|
||
)
|
||
return _command_result(
|
||
FeishuCommandName.WATCHLIST_ADD, FeishuReplyType.TEXT, "自选股", content, response
|
||
)
|
||
if text == "查看自选":
|
||
items = service.watchlist(actor)
|
||
content = "自选股:" + ("、".join(item["symbol"] for item in items) or "暂无")
|
||
response = (
|
||
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
|
||
)
|
||
return _command_result(
|
||
FeishuCommandName.WATCHLIST_LIST, FeishuReplyType.TEXT, "自选股", content, response
|
||
)
|
||
if text == "最新公告":
|
||
items = service.announcements(limit=10)["items"]
|
||
content = (
|
||
"最新公告:\n"
|
||
+ "\n".join(
|
||
f"- {item['announcement_date']} {item['symbol'] or '市场'}:{item['title']}"
|
||
for item in items
|
||
)
|
||
if items
|
||
else "公告元数据尚未接入。"
|
||
)
|
||
response = (
|
||
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
|
||
)
|
||
return _command_result(
|
||
FeishuCommandName.MARKET_ANNOUNCEMENTS,
|
||
FeishuReplyType.TEXT,
|
||
"最新公告",
|
||
content,
|
||
response,
|
||
)
|
||
if industry:
|
||
try:
|
||
data = service.industry_analysis(industry.group(1).strip())
|
||
content = (
|
||
f"{data['industry']} 平均涨跌 {data['average_pct_change']}%\n"
|
||
+ "\n".join(
|
||
f"- {item['name']}({item['symbol']}):{item['pct_change']}%"
|
||
for item in data["items"][:10]
|
||
)
|
||
)
|
||
except HTTPException:
|
||
content = "未找到该行业的最新市场数据。"
|
||
response = (
|
||
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
|
||
)
|
||
return _command_result(
|
||
FeishuCommandName.MARKET_OVERVIEW,
|
||
FeishuReplyType.TEXT,
|
||
"行业分析",
|
||
content,
|
||
response,
|
||
)
|
||
if comparison:
|
||
try:
|
||
content = service.compare_stocks([comparison.group(1), comparison.group(2)])[
|
||
"content"
|
||
]
|
||
except HTTPException:
|
||
content = "至少一只股票缺少可用行情,暂时无法比较。"
|
||
response = (
|
||
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
|
||
)
|
||
return _command_result(
|
||
FeishuCommandName.STOCK_ANALYSIS,
|
||
FeishuReplyType.TEXT,
|
||
"股票对比",
|
||
content,
|
||
response,
|
||
)
|
||
command = FeishuCommandName.STOCK_ANALYSIS if stock else FeishuCommandName.MARKET_OVERVIEW
|
||
if text == "宏观金融分析":
|
||
command = FeishuCommandName.MARKET_MACRO
|
||
try:
|
||
if stock:
|
||
report = service.stock_analysis(stock.group(1), True, actor)
|
||
elif text == "本周市场分析":
|
||
report = service.weekly_overview(include_ai=True, actor=actor)
|
||
elif text == "宏观金融分析":
|
||
report = service.macro_analysis(include_ai=True, actor=actor)
|
||
else:
|
||
report = service.market_overview(include_ai=True, actor=actor)
|
||
except HTTPException:
|
||
content = "未找到该股票的可用行情,请确认代码或先执行行情同步。"
|
||
response = (
|
||
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
|
||
)
|
||
return _command_result(command, FeishuReplyType.TEXT, "股票分析", content, response)
|
||
ai = report.get("ai_analysis") or {}
|
||
if not report.get("data_available"):
|
||
content = "市场数据未接入,暂不生成分析报告。"
|
||
elif not ai.get("ok"):
|
||
content = "AI 当前不可用,本次市场分析报告未发送。"
|
||
else:
|
||
content = report["content"]
|
||
response = None
|
||
if auto_reply:
|
||
if ai.get("ok"):
|
||
if text == "宏观金融分析":
|
||
response = self._send_text_if_configured(chat_id, content, actor)
|
||
else:
|
||
image = self.feishu.upload_image(render_market_chart(report), actor)
|
||
image_key = (image.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["title"],
|
||
report["lines"],
|
||
image_key=image_key,
|
||
image_alt=report["title"],
|
||
)
|
||
response = self.feishu.send_card(card, receive_id=chat_id, actor=actor)
|
||
else:
|
||
response = self._send_text_if_configured(chat_id, content, actor)
|
||
return _command_result(
|
||
command,
|
||
(
|
||
FeishuReplyType.CARD
|
||
if ai.get("ok") and text != "宏观金融分析"
|
||
else FeishuReplyType.TEXT
|
||
),
|
||
report["title"],
|
||
content,
|
||
response,
|
||
report["lines"] if ai.get("ok") else None,
|
||
)
|
||
|
||
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
|
||
if command in {
|
||
FeishuCommandName.RULE_CREATE,
|
||
FeishuCommandName.RULE_DISABLE,
|
||
FeishuCommandName.RULE_ENABLE,
|
||
} and get_settings().read_only_mode:
|
||
content = "当前为只读模式,不能新增或修改学习规则。请由管理员启用操作后重试。"
|
||
provider_response = (
|
||
self._send_text_if_configured(chat_id, content, actor) if auto_reply else None
|
||
)
|
||
return _command_result(
|
||
command,
|
||
FeishuReplyType.TEXT,
|
||
RULE_TITLE,
|
||
content,
|
||
provider_response,
|
||
)
|
||
content = RULE_COMMAND_HELP
|
||
try:
|
||
market_create_match = MARKET_RULE_CREATE_PATTERN.fullmatch(command_text)
|
||
create_match = market_create_match or 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="market" if market_create_match else "global",
|
||
subject="market" if market_create_match else "company",
|
||
priority=priority,
|
||
tags=["feishu", *(["market"] if market_create_match else [])],
|
||
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(
|
||
scope="market" if command_text == "查看市场规则" else None,
|
||
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)
|