""" Proactive Signal Capture Service - 主动式信号捕获服务 核心功能: 1. 飞书群聊监控 - 自动捕获群内讨论的产品/技术相关内容 2. 浏览器标签监控 - 检测用户访问的技术文档、竞品页面 3. GitHub 仓库监控 - 跟踪上游仓库更新、竞品开源项目 4. HackerNews 自动抓取 - 定时抓取技术前沿信息 每个信号都会自动: - 评估战略重要性 - 提取关键技术栈 - 生成任务建议 - 推送给相关角色 """ import asyncio import inspect import json import re from uuid import uuid4 from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy import and_, text from ..core.logging import get_logger from ..database import AsyncSessionLocal from ..models import ( Signal, Project, ProjectDomain, User, ProjectSignalLink, Task, TruthNode, SkillTag, UserProject, UserWorkload, UserSkillBinding, ) from ..config.ai_prompts import ( PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_TASK_ORCHESTRATION, ) from .llm import LLMService from .notification_service import NotificationService from . import workload_service from .adaptive_rag_service import AdaptiveRAGService from .context_cache import context_cache_service from .signal_source_orchestration_service import SignalSourceOrchestrationService from .ai_pmo_kernel_service import AIPMOKernelService from ..utils.project_members import ensure_project_default_members from ..utils.sequence import ensure_project_id_sequence, ensure_signal_id_sequence from ..utils.timezone import CN_TZ, now_cn_aware EMOJI_UNICODE_RE = re.compile( r"[\U0001F300-\U0001F6FF\U0001F700-\U0001F77F\U0001F780-\U0001F7FF" r"\U0001F800-\U0001F8FF\U0001F900-\U0001F9FF\U0001FA00-\U0001FAFF" r"\U00002600-\U000026FF\U00002700-\U000027BF]+" ) ONLY_PUNCT_OR_SPACE_RE = re.compile(r"^[\s\W_]+$", re.UNICODE) MENTION_TAG_RE = re.compile(r"]*>", re.IGNORECASE) CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+") NON_REQUIREMENT_EXACT = { "收到", "收到啦", "好的", "ok", "okk", "okay", "yes", "no", "1", "2", "3", "thanks", "thx", "赞", "辛苦", "你是谁", "你是誰", "你是谁?", "你是誰?", "说话", "在吗", "在不在", "今天什么日期", "今天几号", "今天星期几", "现在几点", "当前日期", } NON_REQUIREMENT_CONTAINS = [ "你是谁", "什么身份", "身份确认", "今天什么日期", "当前日期", "日期疑问", "询问日期", "现在几点", "几点了", "今天周几", "今天星期几", "说话", "在吗", "在不在", ] REQUIREMENT_HINTS = [ "需求", "优化", "改一下", "调整", "修复", "bug", "故障", "报错", "项目", "页面", "接口", "功能", "排版", "样式", "任务", "立项", "上线", "配置", ] logger = get_logger(__name__) AI_THINK_STAGE_ZH = { "capture_start": "开始处理飞书消息", "capture_skip_empty": "消息为空,忽略", "capture_skip_noise": "识别为噪音消息,忽略", "capture_done_ai_pmo": "AI PMO kernel 已完成决策闭环", "capture_kernel_failed": "AI PMO kernel 执行异常", "capture_kernel_failed_rollback_failed": "kernel 异常后回滚失败", "classification_done": "语义分类完成", "classification_parse_failed": "语义分类结果解析失败", "capture_skip_not_requirement": "非需求消息,忽略", "capture_skip_non_requirement": "判定为非需求,忽略", "signal_created": "信号已创建", "project_link_done": "项目关联完成", "project_link_failed": "项目关联失败", "task_orchestrate_failed": "任务编排失败", "capture_done": "飞书消息处理完成", "pipeline_post_orchestrate_failed": "分流后置任务编排失败", "pipeline_post_update_signal_failed": "分流后置信号结果回写失败", "pipeline_post_source_onboard_failed": "分流后置信号源接入编排失败", "task_plan_llm_done": "任务编排 LLM 返回成功", "task_plan_llm_failed": "任务编排 LLM 调用失败", "task_created": "任务已创建", "charter_auto_created": "自动创建宪章节点", "hn_story_captured": "HackerNews 信号已捕获", "hn_monitor_error": "HackerNews 监控异常", "new_project_created_from_signal": "已根据信号自动创建项目", "draft_new_project_brief_failed": "新项目摘要生成失败,已走兜底", } def _build_feishu_rag_conversation_key( *, source_chat_id: Optional[str], source_sender_open_id: Optional[str], sender: Optional[str], ) -> str: chat = str(source_chat_id or "").strip() if not chat: return "" actor = str(source_sender_open_id or "").strip() if not actor: actor = str(sender or "").strip().lower() actor = re.sub(r"\s+", "", actor) actor = actor[:96] if actor else "unknown" return f"feishu:{chat}:{actor}" class ProactiveSignalCaptureService: """主动式信号捕获服务""" def __init__(self, db: AsyncSession): self.db = db self.llm = LLMService() async def capture_from_feishu( self, group_chat_id: str, message_content: str, sender: str, tenant_id: int = 1, proposer_open_id: Optional[str] = None, captured_at: Optional[datetime] = None, mentioned_bot_names: Optional[List[str]] = None, feishu_message_id: Optional[str] = None, chat_type: Optional[str] = None, ) -> Dict[str, Any]: """ 从飞书群聊捕获信号 触发条件: - 提到产品、竞品、技术关键词 - 讨论架构、技术选型 - 分享技术文章 """ trace_id = f"feishu-{uuid4().hex[:12]}" captured_dt = captured_at or now_cn_aware() if captured_dt.tzinfo is None: captured_dt = captured_dt.replace(tzinfo=CN_TZ) proposer_name = self._normalize_single_line_text(sender or "未知用户", max_len=80) or "未知用户" proposer_open_id = self._normalize_single_line_text(proposer_open_id or "", max_len=128) or None self._emit_ai_think( "capture_start", trace_id=trace_id, tenant_id=tenant_id, group_chat_id=group_chat_id, sender=proposer_name, ) cleaned_message = self._sanitize_feishu_message(message_content) if not cleaned_message: self._emit_ai_think("capture_skip_empty", trace_id=trace_id) return {"status": "ignored", "reason": "empty_or_emoji_only"} if self._is_non_requirement_message(cleaned_message) and not self._is_question_like(cleaned_message): self._emit_ai_think( "capture_skip_noise", trace_id=trace_id, message_preview=cleaned_message[:120], ) return {"status": "ignored", "reason": "non_requirement_noise"} kernel = AIPMOKernelService(self.db, tenant_id=tenant_id) try: pipeline_result = await kernel.process_feishu_message( group_chat_id=group_chat_id, message_content=cleaned_message, sender=proposer_name, source_message_id=feishu_message_id, source_sender_open_id=proposer_open_id, captured_at=captured_dt, chat_type=chat_type, ) if not bool(pipeline_result.get("handled")): self._emit_ai_think( "capture_kernel_failed", trace_id=trace_id, reason=pipeline_result.get("reason"), error=pipeline_result.get("error"), ) return { "status": "error", "reason": str(pipeline_result.get("reason") or "kernel_not_handled"), "classification": "error", "project_scope": "error", "linked_project_ids": [], "signal_id": None, "created_project_id": None, "auto_tasks": [], "ai_trace_id": trace_id, "direct_reply_required": True, "direct_reply_text": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", } self._emit_ai_think( "capture_done_ai_pmo", trace_id=trace_id, classification=pipeline_result.get("classification"), linked_project_ids=pipeline_result.get("linked_project_ids", []), work_item_id=pipeline_result.get("work_item_id"), ) pipeline_result = await self._apply_pipeline_post_actions( trace_id=trace_id, tenant_id=tenant_id, cleaned_message=cleaned_message, proposer_name=proposer_name, captured_at=captured_dt, pipeline_result=pipeline_result, ) try: await self._sync_feishu_recent_cache( tenant_id=tenant_id, group_chat_id=group_chat_id, sender=proposer_name, sender_open_id=proposer_open_id, message=cleaned_message, pipeline_result=pipeline_result, ) except Exception as cache_exc: self._emit_ai_think( "capture_recent_cache_sync_failed", trace_id=trace_id, error=str(cache_exc), ) try: asyncio.create_task( AdaptiveRAGService.ingest_feishu_message_async( tenant_id=tenant_id, content=cleaned_message, sender=proposer_name, chat_id=group_chat_id, message_id=feishu_message_id, captured_at=captured_dt, project_id=( int(pipeline_result["linked_project_ids"][0]) if isinstance(pipeline_result.get("linked_project_ids"), list) and pipeline_result.get("linked_project_ids") else None ), ) ) except Exception: pass return pipeline_result except Exception as exc: self._emit_ai_think("capture_kernel_failed", trace_id=trace_id, error=str(exc)) try: await self.db.rollback() except Exception as rollback_exc: self._emit_ai_think( "capture_kernel_failed_rollback_failed", trace_id=trace_id, error=str(rollback_exc), ) return { "status": "error", "reason": "kernel_failed", "classification": "error", "project_scope": "error", "linked_project_ids": [], "signal_id": None, "created_project_id": None, "auto_tasks": [], "ai_trace_id": trace_id, "direct_reply_required": True, "direct_reply_text": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", } projects_context = await self._load_active_projects_context( tenant_id=tenant_id, ) classification = await self._analyze_chat_message( message=cleaned_message, projects_context=projects_context, tenant_id=tenant_id, ) self._emit_ai_think( "classification_done", trace_id=trace_id, classification=classification.get("classification"), confidence=classification.get("confidence"), matched_project_ids=classification.get("matched_project_ids", []), reason=classification.get("reason"), ) if not classification.get("is_requirement", False): self._emit_ai_think( "capture_skip_not_requirement", trace_id=trace_id, reason=classification.get("reason", "non_requirement"), ) return {"status": "ignored", "reason": classification.get("reason", "non_requirement")} project_scope = str(classification.get("classification") or "pending_classification") if project_scope == "non_requirement": self._emit_ai_think( "capture_skip_non_requirement", trace_id=trace_id, reason=classification.get("reason", "non_requirement"), ) return {"status": "ignored", "reason": classification.get("reason", "non_requirement")} # 创建信号 await ensure_signal_id_sequence(self.db) strategic_payload = { "captured_at": captured_dt.isoformat(), "source_context": { "group_id": group_chat_id, "sender": proposer_name, "sender_open_id": proposer_open_id, "mentioned_bot_names": [ self._normalize_single_line_text(n, max_len=80) for n in (mentioned_bot_names or []) if self._normalize_single_line_text(n, max_len=80) ], }, "original_message": (message_content or "")[:500], "cleaned_message": cleaned_message[:500], "ai_analysis": { "requirement_project_scope": project_scope, "reason": classification.get("reason", ""), "confidence": classification.get("confidence", 0.0), "matched_project_ids": classification.get("matched_project_ids", []), "matched_project_titles": classification.get("matched_project_titles", []), "summary": classification.get("summary", ""), }, } signal = Signal( source="Feishu", content=f"[{proposer_name}]: {self._normalize_single_line_text(cleaned_message, max_len=200)}", impact_score=float(classification.get("impact_score") or 0.7), priority=(classification.get("priority") or "medium"), processed=False, strategic_analysis=json.dumps(strategic_payload, ensure_ascii=False), tasks_generated=json.dumps([], ensure_ascii=False), tenant_id=tenant_id ) self.db.add(signal) await self.db.commit() await self.db.refresh(signal) self._emit_ai_think("signal_created", trace_id=trace_id, signal_id=signal.id) # 自动关联到相关项目(失败不阻断后续通知) linked_project_ids: List[int] = [] try: if project_scope == "new_project": new_project_id = await self._auto_create_project_from_new_signal( signal_id=int(signal.id), message=cleaned_message, sender=proposer_name, tenant_id=tenant_id, requested_at=captured_dt, ) linked_project_ids = [new_project_id] if new_project_id else [] else: linked_project_ids = await self._auto_link_to_projects( signal.id, classification, tenant_id, message_content=cleaned_message, ) self._emit_ai_think( "project_link_done", trace_id=trace_id, signal_id=signal.id, linked_project_ids=linked_project_ids, ) except Exception as exc: self._emit_ai_think("project_link_failed", trace_id=trace_id, error=str(exc)) auto_tasks: List[Dict[str, Any]] = [] ai_execution: Dict[str, Any] = { "trace_id": trace_id, "status": "skipped", "reason": "no_linked_project", "project_id": None, "summary": "", "tasks_created": 0, } if linked_project_ids: primary_project_id = int(linked_project_ids[0]) try: auto_tasks, ai_execution = await self._auto_orchestrate_project_tasks( trace_id=trace_id, tenant_id=tenant_id, project_id=primary_project_id, signal_id=int(signal.id), cleaned_message=cleaned_message, classification=classification, proposer_name=proposer_name, captured_at=captured_dt, ) except Exception as exc: self._emit_ai_think( "task_orchestrate_failed", trace_id=trace_id, project_id=primary_project_id, error=str(exc), ) ai_execution = { "trace_id": trace_id, "status": "failed", "reason": str(exc), "project_id": primary_project_id, "summary": "", "tasks_created": 0, } strategic_payload["ai_execution"] = ai_execution signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) signal.processed = bool(linked_project_ids or auto_tasks) await self.db.commit() # 站内通知:右上角消息提醒 target_url = "/ai-steward" if linked_project_ids: target_url = f"/projects/{linked_project_ids[0]}/board" try: notification_service = NotificationService(self.db) await notification_service.notify_signal_created( tenant_id=tenant_id, signal_id=int(signal.id), signal_content=cleaned_message, source="feishu", target_url=target_url, proposer=proposer_name, occurred_at=captured_dt, ) except Exception as exc: self._emit_ai_think("notify_failed", trace_id=trace_id, error=str(exc)) self._emit_ai_think( "capture_done", trace_id=trace_id, signal_id=signal.id, linked_project_ids=linked_project_ids, auto_tasks=len(auto_tasks), ) return { "signal_id": str(signal.id), "status": "captured", "impact_score": signal.impact_score, "auto_tasks": auto_tasks, "project_scope": project_scope, "classification": project_scope, "linked_project_ids": linked_project_ids, "created_project_id": linked_project_ids[0] if project_scope == "new_project" and linked_project_ids else None, "proposer": proposer_name, "ai_trace_id": trace_id, } async def _sync_feishu_recent_cache( self, *, tenant_id: int, group_chat_id: str, sender: str, sender_open_id: Optional[str], message: str, pipeline_result: Dict[str, Any], ) -> None: if not isinstance(pipeline_result, dict): return conversation_key = _build_feishu_rag_conversation_key( source_chat_id=group_chat_id, source_sender_open_id=sender_open_id, sender=sender, ) external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) if external_session_id is None: return answer_text = str(pipeline_result.get("direct_reply_text") or "").strip() if not answer_text: answer_text = self._build_recent_cache_ack_text(pipeline_result) if not answer_text: return recent_messages = await context_cache_service.get_session_messages( tenant_id=int(tenant_id), session_id=int(external_session_id), ) recent_turns = context_cache_service.messages_to_turns(recent_messages) if recent_turns: latest_turn = recent_turns[-1] latest_question = str(latest_turn.get("question") or "").strip() latest_answer = str(latest_turn.get("answer") or "").strip() if latest_question == str(message or "").strip() and latest_answer == answer_text: return await context_cache_service.append_session_messages( tenant_id=int(tenant_id), session_id=int(external_session_id), question=str(message or "")[:3000], answer=answer_text[:4000], ) @staticmethod def _build_recent_cache_ack_text(pipeline_result: Dict[str, Any]) -> str: status = str(pipeline_result.get("status") or "").strip().lower() if status == "captured": return "已收到并记录该需求,已进入信号处理队列。" if status == "ignored": return "已收到该消息,本次未进入需求处理队列。" if status == "error": return str(pipeline_result.get("direct_reply_text") or "系统处理中出现异常,请稍后重试。").strip() return "已收到该消息。" async def _apply_pipeline_post_actions( self, *, trace_id: str, tenant_id: int, cleaned_message: str, proposer_name: str, captured_at: datetime, pipeline_result: Dict[str, Any], ) -> Dict[str, Any]: if not isinstance(pipeline_result, dict): return pipeline_result if str(pipeline_result.get("status") or "").lower() != "captured": return pipeline_result existing_tasks = pipeline_result.get("auto_tasks") if isinstance(existing_tasks, list) and existing_tasks: return pipeline_result linked_project_ids = pipeline_result.get("linked_project_ids") if not isinstance(linked_project_ids, list) or not linked_project_ids: return pipeline_result signal_id = self._to_int(pipeline_result.get("signal_id")) project_id = self._to_int(linked_project_ids[0]) if signal_id is None or project_id is None: return pipeline_result signal = ( await self.db.execute( select(Signal).where( and_( Signal.id == signal_id, Signal.tenant_id == tenant_id, ) ) ) ).scalar_one_or_none() if signal is None: return pipeline_result if self._is_question_like(cleaned_message): return pipeline_result source_orchestration_result: Optional[Dict[str, Any]] = None if SignalSourceOrchestrationService.looks_like_signal_source_request(cleaned_message): try: source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( tenant_id=tenant_id, project_id=project_id, signal_id=signal_id, initiating_text=cleaned_message, requested_via="feishu", operator_user_id=None, dry_run=False, ) except Exception as exc: self._emit_ai_think( "pipeline_post_source_onboard_failed", trace_id=trace_id, signal_id=signal_id, project_id=project_id, error=str(exc), ) else: orchestration_status = str(source_orchestration_result.get("status") or "").lower() if orchestration_status in {"completed", "partial", "planned", "pending_manual"}: auto_tasks = self._build_openclaw_auto_tasks( source_orchestration_result.get("execution") if isinstance(source_orchestration_result.get("execution"), dict) else source_orchestration_result.get("openclaw"), ) try: strategic_payload: Dict[str, Any] = {} raw_strategic = signal.strategic_analysis if isinstance(raw_strategic, dict): strategic_payload = dict(raw_strategic) elif isinstance(raw_strategic, str) and raw_strategic.strip(): parsed = json.loads(raw_strategic) if isinstance(parsed, dict): strategic_payload = parsed strategic_payload["signal_source_orchestration"] = source_orchestration_result strategic_payload["ai_execution"] = source_orchestration_result signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) signal.processed = orchestration_status in {"completed", "partial"} await self.db.commit() except Exception as exc: self._emit_ai_think( "pipeline_post_update_signal_failed", trace_id=trace_id, signal_id=signal_id, project_id=project_id, error=str(exc), ) pipeline_result["auto_tasks"] = auto_tasks pipeline_result["ai_execution"] = source_orchestration_result pipeline_result["signal_source_orchestration"] = source_orchestration_result return pipeline_result signal_type = str(signal.signal_type or "").strip() recommended_action = str(signal.recommended_action or "").strip() should_orchestrate = ( recommended_action in {"create_charter", "trigger_alert"} or signal_type == "新需求" ) if not should_orchestrate: return pipeline_result classification_payload: Dict[str, Any] = { "classification": str(pipeline_result.get("classification") or "signal"), "impact_score": float(signal.impact_score or 0.0), "priority": str(signal.priority or "medium"), "confidence": float(signal.confidence or 0.0), "reason": str(signal.summary or "")[:300], "matched_project_ids": [project_id], "matched_project_titles": [], } try: auto_tasks, ai_execution = await self._auto_orchestrate_project_tasks( trace_id=f"{trace_id}-pipeline", tenant_id=tenant_id, project_id=project_id, signal_id=signal_id, cleaned_message=cleaned_message, classification=classification_payload, proposer_name=proposer_name, captured_at=captured_at, ) except Exception as exc: self._emit_ai_think( "pipeline_post_orchestrate_failed", trace_id=trace_id, signal_id=signal_id, project_id=project_id, error=str(exc), ) return pipeline_result try: strategic_payload: Dict[str, Any] = {} raw_strategic = signal.strategic_analysis if isinstance(raw_strategic, dict): strategic_payload = dict(raw_strategic) elif isinstance(raw_strategic, str) and raw_strategic.strip(): parsed = json.loads(raw_strategic) if isinstance(parsed, dict): strategic_payload = parsed strategic_payload["ai_execution"] = ai_execution signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False) signal.tasks_generated = json.dumps(auto_tasks, ensure_ascii=False) signal.processed = bool(signal.processed or auto_tasks) await self.db.commit() except Exception as exc: self._emit_ai_think( "pipeline_post_update_signal_failed", trace_id=trace_id, signal_id=signal_id, project_id=project_id, error=str(exc), ) pipeline_result["auto_tasks"] = auto_tasks pipeline_result["ai_execution"] = ai_execution return pipeline_result @staticmethod def _build_openclaw_auto_tasks(openclaw_payload: Any) -> List[Dict[str, Any]]: if not isinstance(openclaw_payload, dict): return [] result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} rows = execution.get("results") if isinstance(execution.get("results"), list) else [] items: List[Dict[str, Any]] = [] for row in rows: if not isinstance(row, dict): continue operation_code = str(row.get("operation_code") or "").strip() payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} status = str(row.get("status") or "").strip() if operation_code not in {"task.create", "task.assign", "task.prioritize", "task.status_transition"}: continue if status not in {"applied", "planned", "skipped"}: continue items.append( { "operation_code": operation_code, "status": status, "entity_id": row.get("entity_id"), "task_id": payload.get("task_id"), "title": payload.get("title"), "summary": str(row.get("message") or "").strip(), } ) return items async def capture_from_browser( self, user_id: int, url: str, title: str, content_preview: str, tenant_id: int = 1 ) -> Dict[str, Any]: """ 从浏览器标签页捕获信号 触发条件: - 访问技术文档、API 文档 - 浏览竞品网站、产品页面 - 查看 GitHub issues/PRs - 阅读技术博客 """ # AI 分析页面价值 analysis = await self._analyze_webpage( url, title, content_preview, tenant_id=tenant_id, ) if not analysis['is_valuable']: return {'status': 'ignored', 'reason': analysis['reason']} # 创建信号 await ensure_signal_id_sequence(self.db) signal = Signal( source='Browser', content=f"[浏览] {title}: {content_preview[:200]}", impact_score=analysis['impact_score'], priority=analysis['priority'], processed=True, strategic_analysis=json.dumps({ 'url': url, 'captured_at': now_cn_aware().isoformat(), 'user_id': user_id, 'ai_analysis': analysis.get('analysis', {}) }), tasks_generated=json.dumps([]), tenant_id=tenant_id ) self.db.add(signal) await self.db.commit() await self.db.refresh(signal) # 自动关联到相关项目 await self._auto_link_to_projects(signal.id, analysis, tenant_id) return { 'signal_id': str(signal.id), 'status': 'captured', 'impact_score': signal.impact_score } async def capture_from_github( self, repo_url: str, event_type: str, event_data: Dict[str, Any], tenant_id: int = 1 ) -> Dict[str, Any]: """ 从 GitHub 事件捕获信号 监控事件: - 竞品仓库更新 - 上游依赖更新 - 技术趋势项目发布 """ # AI 分析 GitHub 事件 analysis = await self._analyze_github_event( repo_url, event_type, event_data, tenant_id=tenant_id, ) if not analysis['is_valuable']: return {'status': 'ignored', 'reason': analysis['reason']} # 创建信号 await ensure_signal_id_sequence(self.db) signal = Signal( source='GitHub', content=f"[{event_type}] {repo_url}: {event_data.get('title', event_data.get('message', ''))[:200]}", impact_score=analysis['impact_score'], priority=analysis['priority'], processed=True, strategic_analysis=json.dumps({ 'repo_url': repo_url, 'event_type': event_type, 'event_data': event_data, 'captured_at': now_cn_aware().isoformat(), 'ai_analysis': analysis.get('analysis', {}) }), tasks_generated=json.dumps([]), tenant_id=tenant_id ) self.db.add(signal) await self.db.commit() await self.db.refresh(signal) return { 'signal_id': str(signal.id), 'status': 'captured', 'impact_score': signal.impact_score } async def start_hackernews_monitor( self, interval_minutes: int = 30, tenant_id: int = 1 ): """ 启动 HackerNews 定时监控 定期抓取 HackerNews 首页,分析技术趋势 """ while True: try: # 抓取 HackerNews 首页 from .fetcher import fetch_hn_stories # 兼容三种实现: # 1) async def -> 直接 await # 2) sync def -> to_thread 防阻塞 # 3) sync def 返回 coroutine -> 对返回值再 await if inspect.iscoroutinefunction(fetch_hn_stories): stories = await fetch_hn_stories(limit=30) else: result = await asyncio.to_thread(fetch_hn_stories, 30) stories = await result if inspect.isawaitable(result) else result for story in stories[:10]: # 只分析前10条 # AI 分析每条故事 analysis = await self._analyze_hackernews_story( story, tenant_id=tenant_id, ) if analysis.get('is_valuable'): # 创建信号 await ensure_signal_id_sequence(self.db) signal = Signal( source='HackerNews', content=f"HN: {story.get('title', '')[:200]}", impact_score=analysis['impact_score'], priority=analysis['priority'], processed=True, strategic_analysis=json.dumps({ 'hn_url': story.get('url'), 'hn_points': story.get('points'), 'captured_at': now_cn_aware().isoformat(), 'ai_analysis': analysis.get('analysis', {}) }), tasks_generated=json.dumps(analysis.get('suggested_tasks', [])), tenant_id=tenant_id ) self.db.add(signal) await self.db.commit() self._emit_ai_think( "hn_story_captured", title=str(story.get("title") or "")[:50], ) # 等待下次抓取 await asyncio.sleep(interval_minutes * 60) except Exception as e: self._emit_ai_think("hn_monitor_error", error=str(e)) await asyncio.sleep(interval_minutes * 60) async def _load_active_projects_context(self, tenant_id: int) -> List[Dict[str, Any]]: result = await self.db.execute( select(Project).where( Project.status == "active", Project.tenant_id == tenant_id, ) ) projects = result.scalars().all() rows: List[Dict[str, Any]] = [] for project in projects: rows.append( { "id": int(project.id), "title": (project.title or project.name or "").strip(), "description": (project.description or "").strip()[:300], "status": project.status, } ) return rows def _emit_ai_think(self, stage: str, **payload: Any) -> None: safe_payload: Dict[str, Any] = {} for key, value in payload.items(): safe_payload[key] = self._safe_log_value(value) logger.info("signal_capture_ai_think", stage=stage, **safe_payload) try: stage_zh = AI_THINK_STAGE_ZH.get(stage, stage) now_text = self._format_cn_time(now_cn_aware()) print( f"[{now_text}][AI思考][SignalCapture] {stage_zh} " + json.dumps({"stage": stage, **safe_payload}, ensure_ascii=False) ) except Exception: pass @staticmethod def _safe_log_value(value: Any, max_len: int = 600) -> Any: if isinstance(value, (str, int, float, bool)) or value is None: text = str(value) if value is not None else None if text is None: return None return text if len(text) <= max_len else f"{text[:max_len]}...(truncated)" try: text = json.dumps(value, ensure_ascii=False, default=str) except Exception: text = str(value) return text if len(text) <= max_len else f"{text[:max_len]}...(truncated)" async def _auto_orchestrate_project_tasks( self, *, trace_id: str, tenant_id: int, project_id: int, signal_id: int, cleaned_message: str, classification: Dict[str, Any], proposer_name: str, captured_at: datetime, ) -> tuple[List[Dict[str, Any]], Dict[str, Any]]: project = ( await self.db.execute( select(Project).where( and_( Project.id == project_id, Project.tenant_id == tenant_id, ) ) ) ).scalar_one_or_none() if not project: return [], { "trace_id": trace_id, "status": "skipped", "reason": "project_not_found", "project_id": project_id, "summary": "", "tasks_created": 0, } charter_id = await self._ensure_project_charter_node( tenant_id=tenant_id, project_id=project_id, proposer_name=proposer_name, cleaned_message=cleaned_message, captured_at=captured_at, ) team_members = await self._load_project_team_context( tenant_id=tenant_id, project_id=project_id, owner_id=int(project.owner_id) if project.owner_id else None, ) if not team_members: return [], { "trace_id": trace_id, "status": "skipped", "reason": "no_active_members", "project_id": project_id, "summary": "", "tasks_created": 0, } project_payload = { "id": int(project.id), "title": (project.title or project.name or "").strip(), "description": (project.description or "").strip()[:600], "assigned_role": (project.assigned_role or "").strip(), } signal_payload = { "signal_id": signal_id, "message": cleaned_message[:1200], "classification": classification, "proposer": proposer_name, "captured_at": captured_at.isoformat(), } prompt = PROMPT_FEISHU_TASK_ORCHESTRATION.format( project_json=json.dumps(project_payload, ensure_ascii=False), signal_json=json.dumps(signal_payload, ensure_ascii=False), members_json=json.dumps(team_members, ensure_ascii=False), ) llm_status = "success" llm_error = "" parsed: Dict[str, Any] = {} try: llm_raw = await self.llm.generate( prompt, db=self.db, tenant_id=tenant_id, function_name="feishu_task_orchestration", ) parsed = self._parse_llm_json_response(llm_raw) self._emit_ai_think( "task_plan_llm_done", trace_id=trace_id, project_id=project_id, llm_summary=parsed.get("overall_summary", ""), ) except Exception as exc: llm_status = "failed" llm_error = str(exc) self._emit_ai_think( "task_plan_llm_failed", trace_id=trace_id, project_id=project_id, error=llm_error, ) tasks_payload = parsed.get("tasks") if isinstance(parsed.get("tasks"), list) else [] if not tasks_payload: tasks_payload = self._build_task_orchestration_fallback( cleaned_message=cleaned_message, classification=classification, ) created_items: List[Dict[str, Any]] = [] touched_user_ids: set[int] = set() created_count = 0 for idx, raw_task in enumerate(tasks_payload[:8]): if not isinstance(raw_task, dict): continue title = self._normalize_single_line_text( str(raw_task.get("title") or raw_task.get("task_name") or ""), max_len=64, ) if not title: continue description = self._normalize_multiline_text( str(raw_task.get("description") or cleaned_message), max_len=800, ) priority = self._normalize_priority(raw_task.get("priority")) estimated_hours = self._normalize_estimated_hours(raw_task.get("estimated_hours")) required_skill_names = self._normalize_skill_name_list(raw_task.get("required_skill_names")) required_skill_tag_ids = await self._resolve_skill_tag_ids( tenant_id=tenant_id, skill_names=required_skill_names, ) ai_recommended_assignee_id = self._to_int(raw_task.get("recommended_assignee_id")) assignee_id, decision = self._pick_task_assignee( team_members=team_members, required_skill_tag_ids=required_skill_tag_ids, ai_recommended_assignee_id=ai_recommended_assignee_id, ) task = Task( tenant_id=tenant_id, truth_node_id=charter_id, project_id=project_id, assignee_id=assignee_id, title=title, description=description, required_skill_tag_ids=required_skill_tag_ids, status="todo", priority=priority, estimated_hours=estimated_hours, ai_recommended_assignees={ "trace_id": trace_id, "ai_recommended_assignee_id": ai_recommended_assignee_id, "final_assignee_id": assignee_id, "assignee_reason": self._normalize_single_line_text( str(raw_task.get("assignee_reason") or ""), max_len=300, ), "required_skill_names": required_skill_names, "decision": decision, "confidence": float(raw_task.get("confidence") or 0.0), }, ) self.db.add(task) await self.db.flush() created_count += 1 assignee_name = "" if assignee_id: touched_user_ids.add(int(assignee_id)) match_member = next((m for m in team_members if int(m.get("user_id", 0)) == int(assignee_id)), None) if isinstance(match_member, dict): assignee_name = str(match_member.get("name") or "") item = { "task_id": int(task.id), "task_name": title, "priority": priority, "estimated_hours": float(task.estimated_hours or 0), "required_skill_names": required_skill_names, "required_skill_tag_ids": required_skill_tag_ids, "assignee_id": int(assignee_id) if assignee_id else None, "assignee_name": assignee_name, "reason": decision.get("picked_reason", ""), } created_items.append(item) self._emit_ai_think( "task_created", trace_id=trace_id, project_id=project_id, rank=idx + 1, task_id=task.id, task_name=title, assignee_id=item["assignee_id"], decision=item["reason"], ) await self.db.commit() for uid in sorted(touched_user_ids): await workload_service.refresh_user_assigned_hours_from_tasks( self.db, tenant_id=tenant_id, user_id=uid, ) summary = self._normalize_single_line_text( str(parsed.get("overall_summary") or ""), max_len=200, ) if not summary: summary = "AI 自动拆解并分配飞书需求任务" execution_meta = { "trace_id": trace_id, "status": "success" if created_count > 0 else "skipped", "reason": "ok" if created_count > 0 else "no_valid_tasks", "project_id": project_id, "charter_id": charter_id, "summary": summary, "tasks_created": created_count, "llm_status": llm_status, "llm_error": llm_error[:300] if llm_error else "", } return created_items, execution_meta async def _ensure_project_charter_node( self, *, tenant_id: int, project_id: int, proposer_name: str, cleaned_message: str, captured_at: datetime, ) -> int: existing = ( await self.db.execute( select(TruthNode).where( and_( TruthNode.tenant_id == tenant_id, TruthNode.project_id == project_id, TruthNode.node_type == "charter", ) ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) ) ).scalars().first() if existing: return int(existing.id) project = ( await self.db.execute( select(Project).where( and_( Project.id == project_id, Project.tenant_id == tenant_id, ) ) ) ).scalar_one_or_none() if not project: raise ValueError("project_not_found_for_charter") creator_id = await self._pick_truth_node_creator( tenant_id=tenant_id, project_id=project_id, owner_id=int(project.owner_id) if project.owner_id else None, ) node = TruthNode( tenant_id=tenant_id, node_type="charter", project_id=project_id, created_by=creator_id, title=self._normalize_single_line_text( f"{project.title or project.name or '项目'} 自动宪章", max_len=120, ), content={ "rationale": "由飞书信号自动触发的初版执行宪章", "source": "feishu_auto_orchestration", "proposer": proposer_name, "captured_at": captured_at.isoformat(), "seed_message": cleaned_message[:500], "success_criteria": ["建立可执行任务链", "明确负责人", "控制团队负载"], "constraints": ["后续可由管理员更新细化"], }, version=1, status="active", ) self.db.add(node) await self.db.flush() self._emit_ai_think( "charter_auto_created", project_id=project_id, charter_id=node.id, created_by=creator_id, ) return int(node.id) async def _pick_truth_node_creator( self, *, tenant_id: int, project_id: int, owner_id: Optional[int], ) -> int: if owner_id: owner = ( await self.db.execute( select(User.id).where( and_( User.tenant_id == tenant_id, User.id == owner_id, User.status == "active", ) ).limit(1) ) ).scalars().first() if owner: return int(owner) project_member = ( await self.db.execute( select(UserProject.user_id) .join( User, and_( User.id == UserProject.user_id, User.tenant_id == tenant_id, User.status == "active", User.is_active.is_(True), ), ) .where( and_( UserProject.tenant_id == tenant_id, UserProject.project_id == project_id, ) ) .order_by(UserProject.user_id.asc()) .limit(1) ) ).scalars().first() if project_member: return int(project_member) admin_user = ( await self.db.execute( select(User.id).where( and_( User.tenant_id == tenant_id, User.status == "active", User.is_active.is_(True), User.role.in_(["BOSS", "ADMIN"]), ) ).order_by(User.id.asc()).limit(1) ) ).scalars().first() if admin_user: return int(admin_user) any_active = ( await self.db.execute( select(User.id).where( and_( User.tenant_id == tenant_id, User.status == "active", User.is_active.is_(True), ) ).order_by(User.id.asc()).limit(1) ) ).scalars().first() if any_active: return int(any_active) raise ValueError("no_active_user_for_charter_creator") async def _load_project_team_context( self, *, tenant_id: int, project_id: int, owner_id: Optional[int], ) -> List[Dict[str, Any]]: member_ids_result = await self.db.execute( select(UserProject.user_id).where( and_( UserProject.tenant_id == tenant_id, UserProject.project_id == project_id, ) ) ) member_ids = {int(uid) for uid in member_ids_result.scalars().all()} if owner_id: member_ids.add(int(owner_id)) if not member_ids: manager_result = await self.db.execute( select(User.id).where( and_( User.tenant_id == tenant_id, User.status == "active", User.is_active.is_(True), User.role.in_(["BOSS", "ADMIN"]), ) ).limit(5) ) member_ids = {int(uid) for uid in manager_result.scalars().all()} if not member_ids: return [] users = ( await self.db.execute( select(User).where( and_( User.tenant_id == tenant_id, User.id.in_(sorted(member_ids)), User.status == "active", User.is_active.is_(True), ) ) ) ).scalars().all() if not users: return [] user_ids = [int(u.id) for u in users] workload_rows = ( await self.db.execute( select(UserWorkload).where( and_( UserWorkload.tenant_id == tenant_id, UserWorkload.user_id.in_(user_ids), ) ) ) ).scalars().all() workload_map = {int(item.user_id): item for item in workload_rows} skill_rows = ( await self.db.execute( select( UserSkillBinding.user_id, SkillTag.id, SkillTag.name, SkillTag.slug, UserSkillBinding.proficiency, ) .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) .where( and_( UserSkillBinding.tenant_id == tenant_id, UserSkillBinding.user_id.in_(user_ids), SkillTag.tenant_id == tenant_id, ) ) ) ).all() skill_map: Dict[int, List[Dict[str, Any]]] = {} for uid, sid, name, slug, proficiency in skill_rows: skill_map.setdefault(int(uid), []).append( { "skill_id": int(sid), "name": str(name or "").strip(), "slug": str(slug or "").strip(), "proficiency": int(proficiency or 0), } ) members: List[Dict[str, Any]] = [] for user in users: uid = int(user.id) workload_row = workload_map.get(uid) member_skills = skill_map.get(uid, []) members.append( { "user_id": uid, "name": str(user.name or user.username or uid), "role": str(user.role or ""), "workload_ratio": round( float( workload_row.workload_ratio if workload_row is not None else float(user.workload or 0.0) ), 2, ), "current_assigned_hours": round( float(workload_row.current_assigned_hours or 0.0) if workload_row else 0.0, 2, ), "max_available_hours_per_week": round( float(workload_row.max_available_hours_per_week or 40.0) if workload_row else 40.0, 2, ), "skill_tag_ids": [int(item["skill_id"]) for item in member_skills], "skill_names": [str(item["name"]) for item in member_skills], "skills": member_skills, } ) members.sort(key=lambda x: (float(x.get("workload_ratio") or 0.0), int(x.get("user_id") or 0))) return members async def _resolve_skill_tag_ids( self, *, tenant_id: int, skill_names: List[str], ) -> List[int]: if not skill_names: return [] result = await self.db.execute( select(SkillTag).where(SkillTag.tenant_id == tenant_id) ) tags = result.scalars().all() if not tags: return [] exact: Dict[str, int] = {} fuzzy: List[tuple[str, int]] = [] for tag in tags: name = str(tag.name or "").strip().lower() slug = str(tag.slug or "").strip().lower() if name: exact[name] = int(tag.id) fuzzy.append((name, int(tag.id))) if slug: exact[slug] = int(tag.id) fuzzy.append((slug, int(tag.id))) resolved_ids: List[int] = [] for raw in skill_names: key = str(raw or "").strip().lower() if not key: continue if key in exact: resolved_ids.append(exact[key]) continue matched = None for candidate, sid in fuzzy: if key in candidate or candidate in key: matched = sid break if matched: resolved_ids.append(int(matched)) return sorted(set(resolved_ids)) def _pick_task_assignee( self, *, team_members: List[Dict[str, Any]], required_skill_tag_ids: List[int], ai_recommended_assignee_id: Optional[int], ) -> tuple[Optional[int], Dict[str, Any]]: if not team_members: return None, {"picked_reason": "no_team_members", "candidate_scores": []} candidates: List[Dict[str, Any]] = [] for member in team_members: uid = self._to_int(member.get("user_id")) if not uid: continue skill_ids = {int(v) for v in (member.get("skill_tag_ids") or []) if self._to_int(v)} workload_ratio = float(member.get("workload_ratio") or 0.0) if required_skill_tag_ids: matched = sum(1 for sid in required_skill_tag_ids if sid in skill_ids) skill_score = matched / len(required_skill_tag_ids) else: skill_score = 0.4 workload_score = max(0.0, 140.0 - workload_ratio) / 140.0 ai_bonus = 0.15 if ai_recommended_assignee_id and uid == int(ai_recommended_assignee_id) else 0.0 overload_penalty = 0.2 if workload_ratio >= 150 else 0.0 total_score = (skill_score * 0.7) + (workload_score * 0.3) + ai_bonus - overload_penalty candidates.append( { "user_id": uid, "name": member.get("name"), "skill_score": round(skill_score, 4), "workload_ratio": round(workload_ratio, 2), "total_score": round(total_score, 4), } ) candidates.sort(key=lambda x: (-x["total_score"], x["workload_ratio"], x["user_id"])) if not candidates: return None, {"picked_reason": "no_valid_candidate", "candidate_scores": []} top = candidates[0] if float(top.get("workload_ratio") or 0.0) > 160 and float(top.get("skill_score") or 0.0) < 0.5: return None, { "picked_reason": "all_candidates_overloaded", "candidate_scores": candidates[:3], } picked_uid = int(top["user_id"]) return picked_uid, { "picked_reason": ( f"score={top['total_score']},skill={top['skill_score']}," f"workload={top['workload_ratio']}" ), "candidate_scores": candidates[:3], } def _build_task_orchestration_fallback( self, *, cleaned_message: str, classification: Dict[str, Any], ) -> List[Dict[str, Any]]: summary = self._normalize_single_line_text( str(classification.get("summary") or cleaned_message), max_len=120, ) return [ { "title": "需求澄清与范围确认", "description": f"基于飞书信号补全需求边界、验收口径与优先级。上下文:{summary}", "priority": "high", "estimated_hours": 4, "required_skill_names": ["产品管理", "项目管理"], "recommended_assignee_id": None, "assignee_reason": "fallback_without_llm", "confidence": 0.0, }, { "title": "核心方案落地", "description": f"围绕需求信号推进可交付实现,拆分后端/前端/AI 子任务并排期。上下文:{summary}", "priority": "medium", "estimated_hours": 12, "required_skill_names": ["FastAPI", "React", "LangChain"], "recommended_assignee_id": None, "assignee_reason": "fallback_without_llm", "confidence": 0.0, }, ] @staticmethod def _normalize_priority(value: Any) -> str: priority = str(value or "medium").strip().lower() if priority in {"p0", "critical", "high"}: return "high" if priority in {"p2", "low"}: return "low" return "medium" @staticmethod def _normalize_estimated_hours(value: Any) -> Optional[float]: try: hours = float(value) except Exception: return 8.0 if hours <= 0: return 1.0 return round(min(hours, 80.0), 2) def _normalize_skill_name_list(self, value: Any) -> List[str]: if isinstance(value, list): names = [self._normalize_single_line_text(str(item or ""), max_len=60) for item in value] return [name for name in names if name] if isinstance(value, str): split = re.split(r"[,,/|]", value) names = [self._normalize_single_line_text(part, max_len=60) for part in split] return [name for name in names if name] return [] @staticmethod def _to_int(value: Any) -> Optional[int]: try: if value is None or value == "": return None return int(value) except Exception: return None @staticmethod def _parse_llm_json_response(raw_response: str) -> Dict[str, Any]: content = (raw_response or "").strip() if not content: raise ValueError("empty llm response") if content.startswith("```"): parts = content.split("```") if len(parts) >= 2: content = parts[1].strip() if content.startswith("json"): content = content[4:].strip() try: parsed = json.loads(content.strip()) if isinstance(parsed, dict): return parsed except Exception: pass # 兼容模型返回“解释文本 + JSON”场景:抽取首个 JSON 对象再解析 obj_text = ProactiveSignalCaptureService._extract_first_json_object(content) if not obj_text: raise ValueError("empty llm response") parsed = json.loads(obj_text) if isinstance(parsed, dict): return parsed raise ValueError("llm json is not object") @staticmethod def _extract_first_json_object(text: str) -> Optional[str]: raw = str(text or "") start = raw.find("{") if start < 0: return None depth = 0 in_str = False escaped = False for i in range(start, len(raw)): ch = raw[i] if in_str: if escaped: escaped = False elif ch == "\\": escaped = True elif ch == '"': in_str = False continue if ch == '"': in_str = True continue if ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: return raw[start : i + 1] return None async def _analyze_chat_message( self, *, message: str, projects_context: List[Dict[str, Any]], tenant_id: int, ) -> Dict[str, Any]: """AI 语义分类:existing/new/pending/non_requirement。""" prompt = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION.format( projects_json=json.dumps(projects_context, ensure_ascii=False), message=message[:800], ) try: response = await self.llm.generate( prompt, db=self.db, tenant_id=tenant_id, function_name="feishu_project_signal_classification", ) try: parsed = self._parse_llm_json_response(response) except Exception as parse_exc: self._emit_ai_think( "classification_parse_failed", error=str(parse_exc), response_preview=(response or "")[:500], ) raise classification = str(parsed.get("classification") or "pending_classification").lower() if classification not in { "existing_project", "new_project", "pending_classification", "non_requirement", }: classification = "pending_classification" matched_project_ids: List[int] = [] for item in parsed.get("matched_project_ids", []) or []: try: matched_project_ids.append(int(item)) except Exception: continue matched_titles = [str(x) for x in (parsed.get("matched_project_titles") or []) if str(x).strip()] if classification == "existing_project" and not matched_project_ids: classification = "pending_classification" # 对“明确新建项目意图”进行升级,避免模型过度保守地给 pending。 if classification == "pending_classification" and self._has_explicit_new_project_intent(message): classification = "new_project" raw_is_requirement = parsed.get("is_requirement") if isinstance(raw_is_requirement, bool): is_requirement = raw_is_requirement elif isinstance(raw_is_requirement, str): is_requirement = raw_is_requirement.strip().lower() in {"1", "true", "yes", "y"} else: is_requirement = classification != "non_requirement" if classification in {"existing_project", "new_project", "pending_classification"}: is_requirement = True if classification == "non_requirement": is_requirement = False return { "is_requirement": is_requirement, "reason": str(parsed.get("reason", "") or "AI 判定"), "confidence": float(parsed.get("confidence") or 0.0), "classification": classification, "matched_project_ids": matched_project_ids, "matched_project_titles": matched_titles, "impact_score": float(parsed.get("impact_score") or 0.7), "priority": str(parsed.get("priority", "medium") or "medium"), "summary": str(parsed.get("summary") or "")[:200], } except Exception as exc: self._emit_ai_think("classification_failed", error=str(exc), message_preview=message[:120]) fallback_new_project = self._has_explicit_new_project_intent(message) return { "is_requirement": bool(fallback_new_project), "reason": "AI 分析失败,启用保守策略", "confidence": 0.0, "classification": "new_project" if fallback_new_project else "non_requirement", "matched_project_ids": [], "matched_project_titles": [], "impact_score": 0.6, "priority": "medium", "summary": "", } @staticmethod def _sanitize_feishu_message(message: str) -> str: text = ProactiveSignalCaptureService._normalize_single_line_text(message or "", max_len=2000) if not text: return "" text = MENTION_TAG_RE.sub(" ", text) text = text.replace("@所有人", " ").replace("@everyone", " ") text = re.sub(r"@[_a-zA-Z0-9\-]+", " ", text) text = EMOJI_UNICODE_RE.sub(" ", text) text = re.sub(r"\s+", " ", text).strip() if not text or ONLY_PUNCT_OR_SPACE_RE.match(text): return "" return text @staticmethod def _normalize_single_line_text(text: str, *, max_len: int = 500) -> str: content = str(text or "") if not content: return "" content = content.replace("\ufffd", "") content = CONTROL_CHAR_RE.sub(" ", content) content = re.sub(r"\s+", " ", content).strip() if max_len > 0: content = content[:max_len] return content @staticmethod def _normalize_multiline_text(text: str, *, max_len: int = 2000) -> str: content = str(text or "") if not content: return "" content = content.replace("\ufffd", "") content = content.replace("\r\n", "\n").replace("\r", "\n") content = CONTROL_CHAR_RE.sub(" ", content) content = re.sub(r"[ \t]+", " ", content) content = re.sub(r"\n{3,}", "\n\n", content).strip() if max_len > 0: content = content[:max_len] return content @staticmethod def _format_cn_time(dt: datetime) -> str: safe_dt = dt if safe_dt.tzinfo is None: safe_dt = safe_dt.replace(tzinfo=CN_TZ) cn_tz = timezone(timedelta(hours=8)) return safe_dt.astimezone(cn_tz).strftime("%Y-%m-%d %H:%M:%S") @staticmethod def _is_non_requirement_message(message: str) -> bool: content = (message or "").strip().lower() if not content: return True normalized = re.sub(r"\s+", "", content) if content in NON_REQUIREMENT_EXACT: return True if normalized in NON_REQUIREMENT_EXACT: return True if any(k in normalized for k in NON_REQUIREMENT_CONTAINS) and not any( hint in normalized for hint in REQUIREMENT_HINTS ): return True if len(content) <= 2 and content.isdigit(): return True non_requirement_keywords = [ "收到", "辛苦了", "赞", "哈哈", "good", "nice", "ok", ] if len(content) <= 8 and any(k in content for k in non_requirement_keywords): return True if ONLY_PUNCT_OR_SPACE_RE.match(content): return True return False @staticmethod def _has_explicit_new_project_intent(message: str) -> bool: content = (message or "").strip().lower() if not content: return False explicit_phrases = [ "新建项目", "新增项目", "新开项目", "独立立项", "重新立项", "单独立项", "开个新项目", "做个新项目", "从0到1", ] if any(p in content for p in explicit_phrases): return True # 兼容常见中文表达:“新增个需求,关于 xxx 的项目” if ("新增" in content or "新增个需求" in content) and "项目" in content: return True return False @staticmethod def _is_question_like(message: str) -> bool: text = str(message or "").strip().lower() if not text: return False if "?" in text or "?" in text: return True hints = ("吗", "怎么", "如何", "为什么", "谁", "多少", "是否", "有没有", "几号", "几点", "周几") return any(k in text for k in hints) def _rule_based_chat_signal(self, message: str) -> Optional[Dict[str, Any]]: _ = message return None async def _analyze_webpage(self, url: str, title: str, content: str, tenant_id: int) -> Dict[str, Any]: """AI 分析网页价值""" prompt = f"""分析以下网页内容是否有价值: 标题:{title} URL: {url} 内容摘要:{content[:500]} 请判断: 1. 是否是技术文档、竞品信息、行业趋势? 2. 是否值得团队关注? 3. 影响程度(0-1)? 以 JSON 格式返回: {{ "is_valuable": boolean, "reason": "原因", "impact_score": 0.8, "priority": "high/medium/low", "analysis": {{}}, "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] }} 如果不是有价值内容,is_valuable 设为 false。""" try: response = await self.llm.generate( prompt, db=self.db, tenant_id=tenant_id, ) response = response.strip() if response.startswith('```'): response = response.split('```')[1] if response.startswith('json'): response = response[4:] return json.loads(response.strip()) except: return {'is_valuable': False, 'reason': 'AI 分析失败'} async def _analyze_github_event(self, repo_url: str, event_type: str, event_data: Dict, tenant_id: int) -> Dict[str, Any]: """AI 分析 GitHub 事件""" description = f"{event_type}: {event_data.get('title', event_data.get('message', ''))}" prompt = f"""分析以下 GitHub 事件是否有价值: 仓库:{repo_url} 事件:{description} 请判断: 1. 是否是技术栈更新、安全修复、重要功能? 2. 是否影响我们的项目? 3. 影响程度(0-1)? 以 JSON 格式返回: {{ "is_valuable": boolean, "reason": "原因", "impact_score": 0.8, "priority": "high/medium/low", "analysis": {{}}, "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] }} 如果不是有价值内容,is_valuable 设为 false。""" try: response = await self.llm.generate( prompt, db=self.db, tenant_id=tenant_id, ) response = response.strip() if response.startswith('```'): response = response.split('```')[1] if response.startswith('json'): response = response[4:] return json.loads(response.strip()) except: return {'is_valuable': False, 'reason': 'AI 分析失败'} async def _analyze_hackernews_story(self, story: Dict, tenant_id: int) -> Dict[str, Any]: """AI 分析 HackerNews 故事""" prompt = f"""分析以下 HackerNews 故事是否是重要技术趋势: 标题:{story.get('title', '')} 链接:{story.get('url', '')} 得分:{story.get('points', 0)} 请判断: 1. 是否是行业趋势、技术突破? 2. 是否值得我们关注? 3. 影响程度(0-1)? 以 JSON 格式返回: {{ "is_strategic": boolean, "impact_score": 0.8, "priority": "high/medium/low", "analysis": {{}}, "suggested_tasks": [{{"task_name": "任务", "type": "backend", "estimated_hours": 8}}] }} 如果不是战略性内容,is_strategic 设为 false。""" try: response = await self.llm.generate( prompt, db=self.db, tenant_id=tenant_id, ) response = response.strip() if response.startswith('```'): response = response.split('```')[1] if response.startswith('json'): response = response[4:] result = json.loads(response.strip()) # 重命名字段以匹配其他方法 return { 'is_valuable': result.get('is_strategic', False), 'reason': result.get('reason', ''), 'impact_score': result.get('impact_score', 0.5), 'priority': result.get('priority', 'low'), 'analysis': result.get('analysis', {}), 'suggested_tasks': result.get('suggested_tasks', []) } except: return {'is_valuable': False, 'reason': 'AI 分析失败'} async def _auto_link_to_projects( self, signal_id: int, analysis: Dict[str, Any], tenant_id: int, message_content: Optional[str] = None, ) -> List[int]: """自动关联信号到相关项目""" _ = message_content classification = str(analysis.get("classification") or "").lower() if classification in {"new_project", "pending_classification", "non_requirement"}: return [] if classification == "existing_project": matched_ids: List[int] = [] for pid in analysis.get("matched_project_ids", []) or []: try: matched_ids.append(int(pid)) except Exception: continue matched_ids = sorted(set(matched_ids)) if not matched_ids: return [] result = await self.db.execute( select(Project.id).where( and_( Project.id.in_(matched_ids), Project.status == "active", Project.tenant_id == tenant_id, ) ) ) valid_ids = {int(v) for v in result.scalars().all()} if not valid_ids: return [] confidence = float(analysis.get("confidence") or 0.0) relevance = max(0.5, min(0.98, 0.55 + confidence * 0.4)) link_type = "ai_existing_project_signal" else: result = await self.db.execute( select(Project.id).where( and_(Project.status == "active", Project.tenant_id == tenant_id) ) ) valid_ids = {int(v) for v in result.scalars().all()} if not valid_ids: return [] relevance = 0.7 link_type = "auto_captured" linked_project_ids: List[int] = [] for project_id in sorted(valid_ids): existing = await self.db.execute( text("SELECT id FROM project_signal_links WHERE project_id = :project_id AND signal_id = :signal_id"), {"project_id": project_id, "signal_id": signal_id}, ) if existing.first() is None: from ..models import ProjectSignalLink link = ProjectSignalLink( project_id=project_id, signal_id=signal_id, link_type=link_type, relevance_score=relevance, tenant_id=tenant_id, ) self.db.add(link) linked_project_ids.append(project_id) await self.db.commit() return linked_project_ids async def _auto_create_project_from_new_signal( self, *, signal_id: int, message: str, sender: str, tenant_id: int, requested_at: Optional[datetime] = None, ) -> Optional[int]: """当分类为 new_project 时,自动创建项目并关联当前信号。""" requested_dt = requested_at or now_cn_aware() if requested_dt.tzinfo is None: requested_dt = requested_dt.replace(tzinfo=CN_TZ) project_brief = await self._draft_new_project_brief( message=message, tenant_id=tenant_id, sender=sender, requested_at=requested_dt, ) title = self._normalize_single_line_text(project_brief.get("title") or "", max_len=120) if not title: title = self._normalize_single_line_text(message or "飞书新需求立项", max_len=40) or "飞书新需求立项" raw_desc = self._normalize_multiline_text(project_brief.get("description") or message or "", max_len=1200) requested_at_text = self._format_cn_time(requested_dt) description = self._normalize_multiline_text( f"需求提出人:{sender}\n" f"提出时间:{requested_at_text}\n" f"需求来源:飞书群聊\n\n" f"需求说明:{raw_desc or self._normalize_multiline_text(message, max_len=500)}", max_len=1800, ) assigned_role = str(project_brief.get("primary_role") or "ai_engineer").strip()[:50] or "ai_engineer" owner_id: Optional[int] = None owner_result = await self.db.execute( select(User.id).where( and_( User.tenant_id == tenant_id, User.status == "active", User.role.in_(["BOSS", "ADMIN"]), ) ).order_by(User.id.asc()).limit(1) ) owner_id = owner_result.scalar_one_or_none() await ensure_project_id_sequence(self.db) project = Project( name=title[:80] or "新项目", title=title, description=description, owner_id=owner_id, status="active", assigned_role=assigned_role, signal_count=1, tenant_id=tenant_id, ) self.db.add(project) await self.db.flush() await ensure_project_default_members( self.db, tenant_id=tenant_id, project_id=int(project.id), owner_id=int(project.owner_id) if project.owner_id is not None else None, assigned_role=project.assigned_role, ) existing = await self.db.execute( text("SELECT id FROM project_signal_links WHERE project_id = :project_id AND signal_id = :signal_id"), {"project_id": int(project.id), "signal_id": int(signal_id)}, ) if existing.first() is None: link = ProjectSignalLink( project_id=int(project.id), signal_id=int(signal_id), link_type="ai_new_project_signal", relevance_score=0.98, tenant_id=tenant_id, ) self.db.add(link) await self.db.execute( text("UPDATE signals SET processed = :processed WHERE id = :signal_id"), {"processed": True, "signal_id": int(signal_id)}, ) await self.db.commit() self._emit_ai_think( "new_project_created_from_signal", signal_id=signal_id, project_id=int(project.id), sender=sender, ) return int(project.id) async def _draft_new_project_brief( self, *, message: str, tenant_id: int, sender: str, requested_at: datetime, ) -> Dict[str, Any]: requested_at_text = self._format_cn_time(requested_at) prompt = f"""你是 PMFlow 的立项助手。基于下面需求消息,提炼新项目基础信息。 需求提出人:{sender} 提出时间:{requested_at_text} 需求消息: {message[:1000]} 输出 JSON: {{ "title": "项目标题(15-30字)", "description": "项目简介(80-200字)", "primary_role": "ai_engineer|backend|frontend|devops" }} 只输出 JSON,不要 markdown,不要解释,不要乱码。""" try: raw = await self.llm.generate( prompt, db=self.db, tenant_id=tenant_id, function_name="feishu_new_project_brief", ) parsed = self._parse_llm_json_response(raw) role = str(parsed.get("primary_role") or "ai_engineer").strip().lower() if role not in {"ai_engineer", "backend", "frontend", "devops"}: role = "ai_engineer" return { "title": self._normalize_single_line_text(str(parsed.get("title") or ""), max_len=120), "description": self._normalize_multiline_text(str(parsed.get("description") or ""), max_len=1200), "primary_role": role, } except Exception as exc: self._emit_ai_think("draft_new_project_brief_failed", error=str(exc)) return { "title": self._normalize_single_line_text(message or "飞书新需求立项", max_len=40), "description": self._normalize_multiline_text(message, max_len=300), "primary_role": "ai_engineer", } # 全局服务实例 _signal_capture_service: Optional[ProactiveSignalCaptureService] = None def get_signal_capture_service() -> ProactiveSignalCaptureService: """获取信号捕获服务实例(需要在 async 上下文中使用)""" return ProactiveSignalCaptureService(None) # Placeholder