488 lines
16 KiB
Python
488 lines
16 KiB
Python
"""
|
||
Proactive Signal Capture Service - 主动式信号捕获服务
|
||
|
||
核心功能:
|
||
1. 飞书群聊监控 - 自动捕获群内讨论的产品/技术相关内容
|
||
2. 浏览器标签监控 - 检测用户访问的技术文档、竞品页面
|
||
3. GitHub 仓库监控 - 跟踪上游仓库更新、竞品开源项目
|
||
4. HackerNews 自动抓取 - 定时抓取技术前沿信息
|
||
|
||
每个信号都会自动:
|
||
- 评估战略重要性
|
||
- 提取关键技术栈
|
||
- 生成任务建议
|
||
- 推送给相关角色
|
||
"""
|
||
import asyncio
|
||
import json
|
||
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 text
|
||
|
||
from ..database import AsyncSessionLocal
|
||
from ..models import (
|
||
Signal, Project, ProjectDomain, User,
|
||
ProjectSignalLink, Task
|
||
)
|
||
from .llm import LLMService
|
||
|
||
|
||
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
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
从飞书群聊捕获信号
|
||
|
||
触发条件:
|
||
- 提到产品、竞品、技术关键词
|
||
- 讨论架构、技术选型
|
||
- 分享技术文章
|
||
"""
|
||
# AI 判断是否为有价值信号
|
||
is_signal = await self._analyze_chat_message(
|
||
message_content,
|
||
tenant_id=tenant_id,
|
||
)
|
||
|
||
if not is_signal['is_valuable']:
|
||
return {'status': 'ignored', 'reason': is_signal['reason']}
|
||
|
||
# 创建信号
|
||
from sqlalchemy import text
|
||
next_signal_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1
|
||
|
||
signal = Signal(
|
||
id=next_signal_id,
|
||
source='Feishu',
|
||
content=f"[{sender}]: {message_content[:200]}",
|
||
impact_score=is_signal['impact_score'],
|
||
priority=is_signal['priority'],
|
||
processed=True,
|
||
strategic_analysis=json.dumps({
|
||
'captured_at': datetime.now(timezone.utc).isoformat(),
|
||
'source_context': {
|
||
'group_id': group_chat_id,
|
||
'sender': sender
|
||
},
|
||
'ai_analysis': is_signal.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, is_signal, tenant_id)
|
||
|
||
return {
|
||
'signal_id': str(signal.id),
|
||
'status': 'captured',
|
||
'impact_score': signal.impact_score,
|
||
'auto_tasks': is_signal.get('suggested_tasks', [])
|
||
}
|
||
|
||
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']}
|
||
|
||
# 创建信号
|
||
from sqlalchemy import text
|
||
next_signal_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1
|
||
|
||
signal = Signal(
|
||
id=next_signal_id,
|
||
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': datetime.now(timezone.utc).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']}
|
||
|
||
# 创建信号
|
||
from sqlalchemy import text
|
||
next_signal_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1
|
||
|
||
signal = Signal(
|
||
id=next_signal_id,
|
||
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': datetime.now(timezone.utc).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
|
||
stories = await asyncio.to_thread(fetch_hn_stories)
|
||
|
||
for story in stories[:10]: # 只分析前10条
|
||
# AI 分析每条故事
|
||
analysis = await self._analyze_hackernews_story(
|
||
story,
|
||
tenant_id=tenant_id,
|
||
)
|
||
|
||
if analysis['is_strategic']:
|
||
# 创建信号
|
||
from sqlalchemy import text
|
||
next_signal_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM signals"))).scalar() or 1
|
||
|
||
signal = Signal(
|
||
id=next_signal_id,
|
||
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': datetime.now(timezone.utc).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()
|
||
|
||
print(f"[SignalCapture] Captured HN story: {story.get('title')[:50]}")
|
||
|
||
# 等待下次抓取
|
||
await asyncio.sleep(interval_minutes * 60)
|
||
|
||
except Exception as e:
|
||
print(f"[SignalCapture] HackerNews monitor error: {e}")
|
||
await asyncio.sleep(interval_minutes * 60)
|
||
|
||
async def _analyze_chat_message(self, message: str, tenant_id: int) -> Dict[str, Any]:
|
||
"""AI 分析聊天消息是否有价值"""
|
||
prompt = f"""分析以下群聊消息是否包含有价值的项目或技术信号:
|
||
|
||
消息:{message}
|
||
|
||
请判断:
|
||
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,
|
||
)
|
||
# 清理可能的 markdown
|
||
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_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
|
||
):
|
||
"""自动关联信号到相关项目"""
|
||
# 根据信号内容中的关键词查找相关项目
|
||
# 这里简化处理:将信号关联到所有活跃项目
|
||
result = await self.db.execute(
|
||
select(Project).where(
|
||
Project.status == 'active',
|
||
Project.tenant_id == tenant_id
|
||
)
|
||
)
|
||
projects = result.scalars().all()
|
||
|
||
from sqlalchemy import text
|
||
for project in projects:
|
||
# 检查是否已存在关联
|
||
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:
|
||
next_link_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM project_signal_links"))).scalar() or 1
|
||
from ..models import ProjectSignalLink
|
||
link = ProjectSignalLink(
|
||
id=next_link_id,
|
||
project_id=project.id,
|
||
signal_id=signal_id,
|
||
link_type='auto_captured',
|
||
relevance_score=0.7,
|
||
tenant_id=tenant_id
|
||
)
|
||
self.db.add(link)
|
||
|
||
await self.db.commit()
|
||
|
||
|
||
# 全局服务实例
|
||
_signal_capture_service: Optional[ProactiveSignalCaptureService] = None
|
||
|
||
|
||
def get_signal_capture_service() -> ProactiveSignalCaptureService:
|
||
"""获取信号捕获服务实例(需要在 async 上下文中使用)"""
|
||
return ProactiveSignalCaptureService(None) # Placeholder
|