233 lines
8.0 KiB
Python
233 lines
8.0 KiB
Python
from __future__ import annotations
|
|
|
|
from .shared import *
|
|
|
|
class SignalCaptureExternalSourcesMixin:
|
|
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)"
|
|
|