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.market.chart import render_market_chart from app.modules.market.service import MarketService 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*(.*)$") 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): 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 _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)