PMFlow/backend/app/api/signals.py

463 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""信号相关 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
]