463 lines
16 KiB
Python
463 lines
16 KiB
Python
"""信号相关 API 路由"""
|
||
|
||
import json
|
||
from typing import List, Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.future import select
|
||
|
||
from ..database import get_db
|
||
from ..dependencies.tenant import get_tenant_id
|
||
from ..models import Signal, SignalFeedback, Project, ProjectSignalLink
|
||
from ..schemas import (
|
||
SignalCreate,
|
||
SignalPriority,
|
||
classify_signal_priority,
|
||
SignalFeedbackCreate,
|
||
SignalFeedbackResponse,
|
||
)
|
||
from ..services.fetcher import fetch_hn_stories
|
||
from ..services.analyzer import analyze_impact
|
||
from ..services.llm import llm_service
|
||
from ..services.plane import plane_service
|
||
from ..services.lark import LarkService
|
||
|
||
router = APIRouter(prefix="/signals", tags=["signals"])
|
||
lark_service = LarkService()
|
||
|
||
|
||
@router.get("/")
|
||
async def list_signals(
|
||
roles: Optional[str] = Query(None, description="逗号分隔的角色列表,如 'frontend,backend'"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取信号列表,支持按角色过滤"""
|
||
result = await db.execute(select(Signal).order_by(Signal.impact_score.desc()))
|
||
signals = result.scalars().all()
|
||
|
||
if roles:
|
||
role_list = [r.strip() for r in roles.split(",")]
|
||
filtered_signals = []
|
||
for signal in signals:
|
||
tasks = []
|
||
if signal.tasks_generated:
|
||
try:
|
||
tasks = json.loads(signal.tasks_generated)
|
||
except Exception:
|
||
tasks = []
|
||
|
||
filtered_tasks = [t for t in tasks if t.get("type") in role_list]
|
||
|
||
if filtered_tasks:
|
||
signal_dict = {
|
||
"id": str(signal.id),
|
||
"source": signal.source,
|
||
"content": signal.content,
|
||
"impact_score": signal.impact_score,
|
||
"priority": signal.priority,
|
||
"processed": signal.processed,
|
||
"strategic_analysis": signal.strategic_analysis,
|
||
"tasks_generated": signal.tasks_generated,
|
||
"tasks_filtered": filtered_tasks,
|
||
"created_at": signal.created_at.isoformat() if signal.created_at else None,
|
||
}
|
||
filtered_signals.append(signal_dict)
|
||
|
||
return filtered_signals
|
||
|
||
return signals
|
||
|
||
|
||
@router.post("/", response_model=dict)
|
||
async def create_signal(signal: SignalCreate, db: AsyncSession = Depends(get_db)):
|
||
new_signal = Signal(
|
||
source=signal.source,
|
||
content=signal.content,
|
||
impact_score=signal.impact_score,
|
||
)
|
||
db.add(new_signal)
|
||
await db.commit()
|
||
await db.refresh(new_signal)
|
||
return {"id": str(new_signal.id), "status": "received"}
|
||
|
||
|
||
@router.post("/ingest", response_model=dict)
|
||
async def ingest_signal(
|
||
signal: SignalCreate,
|
||
background_tasks: BackgroundTasks,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
):
|
||
"""接入外部信号,按优先级处理"""
|
||
initial_priority = signal.priority or classify_signal_priority(signal.impact_score)
|
||
|
||
new_signal = Signal(
|
||
source=signal.source,
|
||
content=signal.content,
|
||
impact_score=signal.impact_score,
|
||
priority=initial_priority.value,
|
||
)
|
||
db.add(new_signal)
|
||
await db.commit()
|
||
await db.refresh(new_signal)
|
||
|
||
if initial_priority == SignalPriority.LOW:
|
||
return {
|
||
"id": str(new_signal.id),
|
||
"priority": initial_priority.value,
|
||
"status": "recorded",
|
||
"message": "Low priority signal recorded without processing",
|
||
}
|
||
|
||
async def process_signal_internal():
|
||
try:
|
||
relevant_context = await llm_service.find_relevant_context(
|
||
signal_content=signal.content,
|
||
db_session=db,
|
||
top_k=3,
|
||
min_similarity=0.3,
|
||
tenant_id=tenant_id,
|
||
)
|
||
|
||
strategy_result = await llm_service.evaluate_with_voting_async(
|
||
signal_content=signal.content,
|
||
relevant_context=relevant_context,
|
||
impact_score=signal.impact_score,
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
)
|
||
except RuntimeError:
|
||
if initial_priority == SignalPriority.CRITICAL:
|
||
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
|
||
return {
|
||
"id": str(new_signal.id),
|
||
"priority": initial_priority.value,
|
||
"status": "blocked",
|
||
"processing_mode": "background",
|
||
"message": "预算超限,AI 调用被阻止(hard limit)",
|
||
}
|
||
|
||
is_strategic = strategy_result.get("is_strategic", False)
|
||
decision_proposal = strategy_result.get("decision_proposal", {})
|
||
tasks = strategy_result.get("tasks", [])
|
||
confidence = strategy_result.get("confidence", 0.0)
|
||
|
||
final_priority = classify_signal_priority(signal.impact_score, confidence)
|
||
new_signal.priority = final_priority.value
|
||
|
||
sync_result = None
|
||
notification_sent = False
|
||
|
||
if final_priority in [SignalPriority.CRITICAL, SignalPriority.HIGH]:
|
||
if is_strategic and tasks:
|
||
sync_result = await plane_service.sync_tasks(
|
||
tasks, is_urgent=(final_priority == SignalPriority.CRITICAL)
|
||
)
|
||
default_chat_id = "oc_1234567890"
|
||
await lark_service.send_strategic_alert(
|
||
default_chat_id, signal.content, decision_proposal, tasks
|
||
)
|
||
notification_sent = True
|
||
new_signal.impact_score = 1.0
|
||
|
||
analysis_data = {
|
||
"decision_proposal": decision_proposal,
|
||
"competitiveness_radar": strategy_result.get("competitiveness_radar"),
|
||
"detected_tech_stacks": strategy_result.get("detected_tech_stacks", []),
|
||
"related_knowledge_nodes": strategy_result.get("related_knowledge_nodes", []),
|
||
"rag_context_used": len(relevant_context) if relevant_context else 0,
|
||
"rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])],
|
||
}
|
||
|
||
if "_voting" in strategy_result:
|
||
voting_info = strategy_result["_voting"]
|
||
analysis_data["voting"] = {
|
||
"enabled": voting_info.get("enabled"),
|
||
"models_used": voting_info.get("models_used"),
|
||
"strategic_votes": voting_info.get("strategic_votes"),
|
||
"total_votes": voting_info.get("total_votes"),
|
||
"unanimous": voting_info.get("unanimous"),
|
||
}
|
||
|
||
new_signal.strategic_analysis = json.dumps(analysis_data)
|
||
new_signal.tasks_generated = json.dumps(tasks)
|
||
new_signal.processed = True
|
||
|
||
await db.commit()
|
||
await db.refresh(new_signal)
|
||
|
||
return {
|
||
"id": str(new_signal.id),
|
||
"priority": final_priority.value,
|
||
"is_strategic": is_strategic,
|
||
"confidence": confidence,
|
||
"decision_proposal": decision_proposal,
|
||
"rag_context_used": len(relevant_context) if relevant_context else 0,
|
||
"voting": strategy_result.get("_voting"),
|
||
"tasks_generated": len(tasks),
|
||
"sync_result": sync_result,
|
||
"notification_sent": notification_sent,
|
||
}
|
||
|
||
if initial_priority == SignalPriority.CRITICAL:
|
||
result = await process_signal_internal()
|
||
result["processing_mode"] = "synchronous"
|
||
return result
|
||
else:
|
||
background_tasks.add_task(process_signal_internal)
|
||
return {
|
||
"id": str(new_signal.id),
|
||
"priority": initial_priority.value,
|
||
"status": "queued",
|
||
"processing_mode": "background",
|
||
"message": f"{initial_priority.value.upper()} priority signal queued for processing",
|
||
}
|
||
|
||
|
||
@router.post("/fetch", response_model=dict)
|
||
async def trigger_signal_fetch(
|
||
background_tasks: BackgroundTasks,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
):
|
||
"""手动触发抓取并分析信号"""
|
||
|
||
def _stop_ollama():
|
||
import subprocess
|
||
import signal
|
||
import os
|
||
|
||
killed = False
|
||
try:
|
||
result = subprocess.run(
|
||
["pgrep", "-f", "ollama"],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
if result.returncode == 0:
|
||
for pid in result.stdout.strip().split("\n"):
|
||
if pid:
|
||
try:
|
||
os.kill(int(pid), signal.SIGTERM)
|
||
killed = True
|
||
except ProcessLookupError:
|
||
pass
|
||
except Exception as e:
|
||
print(f"[AI] pgrep failed: {e}")
|
||
|
||
if not killed:
|
||
try:
|
||
result = subprocess.run(
|
||
["pkill", "-9", "-f", "ollama"],
|
||
capture_output=True,
|
||
timeout=5,
|
||
)
|
||
killed = result.returncode == 0
|
||
except Exception as e:
|
||
print(f"[AI] pkill failed: {e}")
|
||
|
||
if killed:
|
||
print("[AI] Ollama stopped - analysis complete")
|
||
|
||
raw_stories = await fetch_hn_stories(limit=30)
|
||
new_signals_count = 0
|
||
processed_count = 0
|
||
|
||
for story in raw_stories:
|
||
result = await db.execute(select(Signal).where(Signal.content == story["content"]))
|
||
if result.scalars().first():
|
||
continue
|
||
|
||
impact = analyze_impact(story["content"])
|
||
|
||
if impact > 0.4:
|
||
initial_priority = classify_signal_priority(impact)
|
||
|
||
new_signal = Signal(
|
||
source=story["source"],
|
||
content=story["content"],
|
||
impact_score=impact,
|
||
priority=initial_priority.value,
|
||
)
|
||
db.add(new_signal)
|
||
await db.commit()
|
||
await db.refresh(new_signal)
|
||
new_signals_count += 1
|
||
|
||
if initial_priority == SignalPriority.LOW:
|
||
continue
|
||
|
||
async def process_fetched():
|
||
nonlocal processed_count
|
||
try:
|
||
relevant_context = await llm_service.find_relevant_context(
|
||
signal_content=story["content"],
|
||
db_session=db,
|
||
top_k=3,
|
||
min_similarity=0.3,
|
||
tenant_id=tenant_id,
|
||
)
|
||
strategy_result = await llm_service.evaluate_with_voting_async(
|
||
signal_content=story["content"],
|
||
relevant_context=relevant_context,
|
||
impact_score=impact,
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
)
|
||
except RuntimeError:
|
||
return
|
||
is_strategic = strategy_result.get("is_strategic", False)
|
||
decision_proposal = strategy_result.get("decision_proposal", {})
|
||
confidence = strategy_result.get("confidence", 0.0)
|
||
final_priority = classify_signal_priority(impact, confidence)
|
||
new_signal.priority = final_priority.value
|
||
|
||
analysis_data = {
|
||
"decision_proposal": decision_proposal,
|
||
"competitiveness_radar": strategy_result.get("competitiveness_radar"),
|
||
"detected_tech_stacks": strategy_result.get("detected_tech_stacks", []),
|
||
"related_knowledge_nodes": strategy_result.get(
|
||
"related_knowledge_nodes", []
|
||
),
|
||
"rag_context_used": len(relevant_context) if relevant_context else 0,
|
||
"rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])],
|
||
"confidence": confidence,
|
||
"is_strategic": is_strategic,
|
||
}
|
||
if "_voting" in strategy_result:
|
||
vi = strategy_result["_voting"]
|
||
analysis_data["voting"] = {
|
||
"enabled": vi.get("enabled"),
|
||
"models_used": vi.get("models_used"),
|
||
"strategic_votes": vi.get("strategic_votes"),
|
||
"total_votes": vi.get("total_votes"),
|
||
"unanimous": vi.get("unanimous"),
|
||
}
|
||
new_signal.strategic_analysis = json.dumps(analysis_data)
|
||
new_signal.tasks_generated = json.dumps(strategy_result.get("tasks", []))
|
||
new_signal.processed = True
|
||
await db.commit()
|
||
processed_count += 1
|
||
|
||
if initial_priority in [SignalPriority.MEDIUM, SignalPriority.HIGH]:
|
||
background_tasks.add_task(process_fetched)
|
||
else:
|
||
await process_fetched()
|
||
|
||
_stop_ollama()
|
||
|
||
return {
|
||
"status": "success",
|
||
"new_signals_count": new_signals_count,
|
||
"processed_count": processed_count,
|
||
"message": f"Fetched {new_signals_count} signals, {processed_count} analyzed",
|
||
}
|
||
|
||
|
||
@router.get("/{signal_id}/projects")
|
||
async def get_signal_projects(
|
||
signal_id: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取信号关联的项目"""
|
||
try:
|
||
sid = int(signal_id)
|
||
except (ValueError, TypeError):
|
||
raise HTTPException(status_code=400, detail="Invalid signal_id")
|
||
|
||
signal_result = await db.execute(select(Signal).where(Signal.id == sid))
|
||
if not signal_result.scalar_one_or_none():
|
||
raise HTTPException(status_code=404, detail="Signal not found")
|
||
|
||
result = await db.execute(
|
||
select(Project, ProjectSignalLink)
|
||
.join(ProjectSignalLink, Project.id == ProjectSignalLink.project_id)
|
||
.where(ProjectSignalLink.signal_id == sid)
|
||
)
|
||
rows = result.all()
|
||
|
||
return [
|
||
{
|
||
"id": str(p.id),
|
||
"title": p.title,
|
||
"status": p.status,
|
||
"assigned_role": p.assigned_role,
|
||
"link_type": link.link_type,
|
||
"relevance_score": link.relevance_score,
|
||
}
|
||
for p, link in rows
|
||
]
|
||
|
||
|
||
@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse)
|
||
async def submit_signal_feedback(
|
||
signal_id: int,
|
||
feedback: SignalFeedbackCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""提交信号反馈"""
|
||
result = await db.execute(select(Signal).where(Signal.id == signal_id))
|
||
signal = result.scalar_one_or_none()
|
||
if not signal:
|
||
raise HTTPException(status_code=404, detail="Signal not found")
|
||
|
||
ai_predicted_strategic = False
|
||
ai_confidence = 0.0
|
||
if signal.strategic_analysis:
|
||
try:
|
||
analysis = json.loads(signal.strategic_analysis)
|
||
ai_predicted_strategic = analysis.get("is_strategic", signal.impact_score >= 0.9)
|
||
ai_confidence = analysis.get("confidence", 0.0)
|
||
except Exception:
|
||
ai_predicted_strategic = signal.impact_score >= 0.9
|
||
|
||
new_feedback = SignalFeedback(
|
||
signal_id=signal_id,
|
||
was_strategic=feedback.was_strategic,
|
||
reason=feedback.reason,
|
||
user_id=feedback.user_id,
|
||
ai_predicted_strategic=ai_predicted_strategic,
|
||
ai_confidence=ai_confidence,
|
||
)
|
||
db.add(new_feedback)
|
||
await db.commit()
|
||
await db.refresh(new_feedback)
|
||
|
||
is_correct = ai_predicted_strategic == feedback.was_strategic
|
||
|
||
return SignalFeedbackResponse(
|
||
id=new_feedback.id,
|
||
signal_id=new_feedback.signal_id,
|
||
was_strategic=new_feedback.was_strategic,
|
||
ai_predicted_strategic=new_feedback.ai_predicted_strategic,
|
||
ai_confidence=new_feedback.ai_confidence,
|
||
is_correct=is_correct,
|
||
)
|
||
|
||
|
||
@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse])
|
||
async def get_signal_feedbacks(
|
||
signal_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取某信号的全部反馈"""
|
||
result = await db.execute(
|
||
select(SignalFeedback)
|
||
.where(SignalFeedback.signal_id == signal_id)
|
||
.order_by(SignalFeedback.created_at.desc())
|
||
)
|
||
feedbacks = result.scalars().all()
|
||
|
||
return [
|
||
SignalFeedbackResponse(
|
||
id=f.id,
|
||
signal_id=f.signal_id,
|
||
was_strategic=f.was_strategic,
|
||
ai_predicted_strategic=f.ai_predicted_strategic,
|
||
ai_confidence=f.ai_confidence or 0.0,
|
||
is_correct=f.ai_predicted_strategic == f.was_strategic,
|
||
)
|
||
for f in feedbacks
|
||
]
|