1040 lines
37 KiB
Python
1040 lines
37 KiB
Python
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.future import select
|
|
from typing import List
|
|
from pydantic import BaseModel
|
|
from enum import Enum
|
|
import uuid
|
|
import asyncio
|
|
|
|
from .database import get_db, engine, Base
|
|
from .models import ContextNode, Experiment, Signal, SignalFeedback
|
|
from .services.fetcher import fetch_hn_stories
|
|
from .services.analyzer import analyze_impact
|
|
from .services.llm import llm_service
|
|
|
|
app = FastAPI(title="Neural Sync API")
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # In production, specify allowed origins
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Signal Priority Enum
|
|
class SignalPriority(str, Enum):
|
|
CRITICAL = "critical" # impact >= 0.9: Immediate processing + notification
|
|
HIGH = "high" # 0.7 <= impact < 0.9: Async processing + notification
|
|
MEDIUM = "medium" # 0.5 <= impact < 0.7: Async processing only
|
|
LOW = "low" # impact < 0.5: Record only, no processing
|
|
|
|
def classify_signal_priority(impact_score: float, confidence: float = 0.0) -> SignalPriority:
|
|
"""
|
|
Classify signal priority based on impact score and confidence.
|
|
|
|
Priority Matrix:
|
|
- CRITICAL: impact >= 0.9 (regardless of confidence)
|
|
- HIGH: 0.7 <= impact < 0.9 AND confidence >= 0.6
|
|
- MEDIUM: 0.5 <= impact < 0.7 OR (0.7 <= impact < 0.9 AND confidence < 0.6)
|
|
- LOW: impact < 0.5
|
|
"""
|
|
if impact_score >= 0.9:
|
|
return SignalPriority.CRITICAL
|
|
elif impact_score >= 0.7 and confidence >= 0.6:
|
|
return SignalPriority.HIGH
|
|
elif impact_score >= 0.5 or (impact_score >= 0.7 and confidence < 0.6):
|
|
return SignalPriority.MEDIUM
|
|
else:
|
|
return SignalPriority.LOW
|
|
|
|
# Pydantic Models
|
|
class SignalCreate(BaseModel):
|
|
source: str
|
|
content: str
|
|
impact_score: float
|
|
priority: SignalPriority = None # Will be auto-calculated if not provided
|
|
|
|
class ExperimentCreate(BaseModel):
|
|
title: str
|
|
hypothesis: str
|
|
target_metric: str
|
|
linked_context_nodes: List[uuid.UUID] = []
|
|
|
|
class ExperimentUpdate(BaseModel):
|
|
current_score: float
|
|
|
|
class ContextNodeCreate(BaseModel):
|
|
content: str
|
|
|
|
from .services.plane import plane_service
|
|
|
|
class TaskBreakdownRequest(BaseModel):
|
|
description: str
|
|
sync_to_plane: bool = False
|
|
|
|
class TaskItem(BaseModel):
|
|
task_name: str
|
|
type: str
|
|
estimated_hours: float
|
|
|
|
from typing import List, Optional
|
|
|
|
class TaskBreakdownResponse(BaseModel):
|
|
tasks: List[TaskItem]
|
|
sync_result: Optional[dict] = None
|
|
|
|
# Endpoints
|
|
|
|
@app.on_event("startup")
|
|
async def startup():
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
@app.get("/signals/")
|
|
async def list_signals(db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Signal).order_by(Signal.impact_score.desc()))
|
|
signals = result.scalars().all()
|
|
return signals
|
|
|
|
@app.post("/signals/", 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"}
|
|
|
|
from fastapi import FastAPI, Depends, HTTPException, Request
|
|
|
|
# ... existing imports ...
|
|
|
|
# Pydantic Models for Webhook
|
|
class WebhookPayload(BaseModel):
|
|
# This structure depends on the Git provider (GitHub/GitLab)
|
|
# We'll support a generic structure or try to parse flexibly
|
|
commits: List[dict] = []
|
|
repository: dict = {}
|
|
ref: str = ""
|
|
|
|
# ... existing models ...
|
|
|
|
from .services.lark import LarkService
|
|
|
|
lark_service = LarkService()
|
|
|
|
@app.post("/webhooks/git")
|
|
async def handle_git_webhook(payload: dict, db: AsyncSession = Depends(get_db)):
|
|
"""
|
|
Receives Git webhooks.
|
|
1. Extract Task ID and Code Diff.
|
|
2. AI Code Audit.
|
|
3. If Passed -> Update Plane & Signal status.
|
|
"""
|
|
import re
|
|
import json
|
|
|
|
print(f"Received Webhook Payload: {payload}")
|
|
|
|
commits = payload.get("commits", [])
|
|
if not commits:
|
|
return {"status": "ignored", "reason": "No commits found"}
|
|
|
|
updated_tasks = []
|
|
|
|
for commit in commits:
|
|
message = commit.get("message", "")
|
|
# Mocking diff retrieval - in real life, fetch from commit.url or diff_url
|
|
# Here we assume the payload might simulate a diff or we rely on message/added files
|
|
code_diff = commit.get("patch") or str(commit.get("modified", []))
|
|
|
|
# Regex to find Task IDs like #PROJ-123 or #123 or #Task-Name
|
|
# Matching task names is harder, let's stick to ID or simple name match if ID not found
|
|
task_matches = re.findall(r'#([A-Za-z0-9-]+-\d+|[A-Za-z0-9]+-\d+)', message)
|
|
|
|
for task_id in task_matches:
|
|
print(f"Processing Task ID: {task_id}")
|
|
|
|
# 1. Find the Signal that generated this task (to get task details/context)
|
|
# We search in the JSON column 'tasks_generated'
|
|
# Postgres JSON search is powerful but for MVP let's do a simple check or assume we have context.
|
|
# Actually, to Audit properly, we need the Task Description.
|
|
# Let's search specifically for this task_id in all signals.
|
|
|
|
# Optimized search: Select signals where tasks_generated contains the task_id
|
|
# For MVP with SQLite/Simple Postgres usage: Fetch recent signals and check in python
|
|
|
|
result = await db.execute(select(Signal).order_by(Signal.created_at.desc()).limit(20))
|
|
signals = result.scalars().all()
|
|
|
|
target_task = None
|
|
target_signal = None
|
|
|
|
for sig in signals:
|
|
if not sig.tasks_generated: continue
|
|
try:
|
|
tasks = json.loads(sig.tasks_generated)
|
|
for t in tasks:
|
|
# Match by Plane ID or Key
|
|
if t.get("plane_key") == task_id or t.get("plane_id") == task_id:
|
|
target_task = t
|
|
target_signal = sig
|
|
break
|
|
except:
|
|
continue
|
|
if target_task: break
|
|
|
|
task_name = target_task.get("task_name") if target_task else f"Task {task_id}"
|
|
|
|
# 2. AI Code Audit
|
|
audit_result = llm_service.audit_code_change(task_name, message, code_diff)
|
|
|
|
if audit_result.get("passed"):
|
|
print(f"✅ Audit Passed for {task_id}")
|
|
# 3. Update Plane
|
|
plane_res = await plane_service.update_issue_status(task_id, status="Done")
|
|
|
|
# 4. Update Signal DB (Mark task as Done in JSON)
|
|
if target_signal and target_task:
|
|
tasks = json.loads(target_signal.tasks_generated)
|
|
for t in tasks:
|
|
if t.get("plane_key") == task_id or t.get("plane_id") == task_id:
|
|
t["status"] = "Done"
|
|
t["audit_feedback"] = audit_result.get("reason")
|
|
# Record completion time for metrics
|
|
if "completed_at" not in t:
|
|
from datetime import datetime
|
|
t["completed_at"] = datetime.utcnow().isoformat()
|
|
|
|
target_signal.tasks_generated = json.dumps(tasks)
|
|
await db.commit()
|
|
|
|
updated_tasks.append({
|
|
"task_id": task_id,
|
|
"status": "Done",
|
|
"audit": audit_result
|
|
})
|
|
else:
|
|
print(f"❌ Audit Failed for {task_id}: {audit_result.get('reason')}")
|
|
updated_tasks.append({
|
|
"task_id": task_id,
|
|
"status": "Failed",
|
|
"audit": audit_result
|
|
})
|
|
|
|
return {"status": "processed", "updated_tasks": updated_tasks}
|
|
|
|
@app.post("/webhooks/lark")
|
|
async def handle_lark_webhook(request: Request):
|
|
"""
|
|
Receives Lark Bot Webhook events.
|
|
When a message is received:
|
|
1. Extract content.
|
|
2. Call AI Task Breakdown.
|
|
3. Sync to Plane.
|
|
4. Reply with a Card Message.
|
|
"""
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return {"status": "error", "message": "Invalid JSON"}
|
|
|
|
# Handle Challenge (Lark verification)
|
|
if "challenge" in data:
|
|
return {"challenge": data["challenge"]}
|
|
|
|
event = data.get("event", {})
|
|
if not event:
|
|
return {"status": "ignored"}
|
|
|
|
# Check if it's a message event
|
|
if event.get("type") == "message" or event.get("msg_type") == "text":
|
|
message_content = event.get("text_without_at_bot") or event.get("content", "")
|
|
# Parse JSON string content if needed, Lark sends JSON string for 'content'
|
|
if isinstance(message_content, str) and message_content.startswith("{"):
|
|
import json
|
|
try:
|
|
content_obj = json.loads(message_content)
|
|
message_content = content_obj.get("text", "")
|
|
except:
|
|
pass
|
|
|
|
message_content = message_content.strip()
|
|
chat_id = event.get("open_chat_id") or event.get("chat_id") # V1 vs V2 events
|
|
|
|
if not message_content or not chat_id:
|
|
return {"status": "ignored", "reason": "No content or chat_id"}
|
|
|
|
print(f"Received Lark Message: {message_content} from {chat_id}")
|
|
|
|
# Call Task Breakdown Engine
|
|
try:
|
|
tasks = llm_service.generate_task_breakdown(message_content)
|
|
|
|
# Sync to Plane
|
|
if tasks:
|
|
await plane_service.sync_tasks(tasks)
|
|
|
|
# Send Card Reply
|
|
# In simulation, this will just print
|
|
await lark_service.send_card_message(chat_id, message_content, tasks)
|
|
|
|
except Exception as e:
|
|
print(f"Error processing Lark message: {e}")
|
|
|
|
return {"status": "processed"}
|
|
@app.post("/signals/ingest", response_model=dict)
|
|
async def ingest_signal(signal: SignalCreate, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db)):
|
|
"""
|
|
Ingests external signals (RSS/Crawler) with priority-based processing.
|
|
|
|
Processing Strategy by Priority:
|
|
- CRITICAL (impact >= 0.9): Immediate processing + notification
|
|
- HIGH (0.7 <= impact < 0.9): Async processing + notification
|
|
- MEDIUM (0.5 <= impact < 0.7): Async processing only
|
|
- LOW (impact < 0.5): Record only, no processing
|
|
"""
|
|
# 1. Calculate initial priority
|
|
initial_priority = signal.priority or classify_signal_priority(signal.impact_score)
|
|
|
|
# 2. Save Signal with priority
|
|
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)
|
|
|
|
# 3. Priority-based processing
|
|
if initial_priority == SignalPriority.LOW:
|
|
# LOW priority: Record only, no processing
|
|
print(f"[PRIORITY] LOW priority signal recorded: {new_signal.id}")
|
|
return {
|
|
"id": str(new_signal.id),
|
|
"priority": initial_priority.value,
|
|
"status": "recorded",
|
|
"message": "Low priority signal recorded without processing"
|
|
}
|
|
|
|
# For MEDIUM, HIGH, and CRITICAL: process the signal
|
|
# Define async processing function
|
|
async def process_signal_internal():
|
|
# RAG: Find relevant context from knowledge base
|
|
relevant_context = await llm_service.find_relevant_context(
|
|
signal_content=signal.content,
|
|
db_session=db,
|
|
top_k=3,
|
|
min_similarity=0.3
|
|
)
|
|
|
|
# Strategic Assessment with RAG context and multi-model voting
|
|
strategy_result = llm_service.evaluate_with_voting(
|
|
signal_content=signal.content,
|
|
relevant_context=relevant_context,
|
|
impact_score=signal.impact_score
|
|
)
|
|
|
|
is_strategic = strategy_result.get("is_strategic", False)
|
|
decision_proposal = strategy_result.get("decision_proposal", {})
|
|
strategy_desc = decision_proposal.get("recommendation", "")
|
|
tasks = strategy_result.get("tasks", [])
|
|
confidence = strategy_result.get("confidence", 0.0)
|
|
|
|
# Re-calculate priority with confidence
|
|
final_priority = classify_signal_priority(signal.impact_score, confidence)
|
|
new_signal.priority = final_priority.value
|
|
|
|
# Process based on priority
|
|
sync_result = None
|
|
notification_sent = False
|
|
|
|
if final_priority in [SignalPriority.CRITICAL, SignalPriority.HIGH]:
|
|
if is_strategic and tasks:
|
|
# Sync to Plane
|
|
print(f"[PRIORITY] {final_priority.value.upper()}: Strategic Signal Detected! Strategy: {strategy_desc}")
|
|
sync_result = await plane_service.sync_tasks(tasks, is_urgent=(final_priority == SignalPriority.CRITICAL))
|
|
|
|
# Lark Strategic Alert (for HIGH and CRITICAL)
|
|
default_chat_id = "oc_1234567890"
|
|
await lark_service.send_strategic_alert(default_chat_id, signal.content, decision_proposal, tasks)
|
|
notification_sent = True
|
|
|
|
# Update signal impact score to reflect strategic importance
|
|
new_signal.impact_score = 1.0
|
|
|
|
# Store strategic analysis
|
|
import json
|
|
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
|
|
}
|
|
|
|
# Process based on priority
|
|
if initial_priority == SignalPriority.CRITICAL:
|
|
# CRITICAL: Process immediately
|
|
result = await process_signal_internal()
|
|
result["processing_mode"] = "synchronous"
|
|
return result
|
|
else:
|
|
# MEDIUM and HIGH: Process in background
|
|
print(f"[PRIORITY] {initial_priority.value.upper()}: Signal queued for background processing: {new_signal.id}")
|
|
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"
|
|
}
|
|
|
|
@app.post("/signals/fetch", response_model=dict)
|
|
async def trigger_signal_fetch(background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db)):
|
|
"""
|
|
Manually trigger the AI Agent to fetch and analyze signals from the web.
|
|
This now includes full AI strategic analysis for each signal.
|
|
"""
|
|
# 1. Fetch raw stories
|
|
raw_stories = await fetch_hn_stories(limit=30)
|
|
|
|
new_signals_count = 0
|
|
processed_count = 0
|
|
|
|
for story in raw_stories:
|
|
# 2. Check for duplicates (Simple check by content)
|
|
result = await db.execute(select(Signal).where(Signal.content == story['content']))
|
|
if result.scalars().first():
|
|
print(f"Duplicate signal skipped: {story['content'][:30]}...")
|
|
continue
|
|
|
|
# 3. Analyze Impact (keyword-based)
|
|
impact = analyze_impact(story['content'])
|
|
|
|
# 4. Create signal and trigger full AI analysis
|
|
if impact > 0.4: # Only save relevant stuff
|
|
# Use the same logic as /signals/ingest
|
|
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
|
|
|
|
# Skip AI analysis for LOW priority
|
|
if initial_priority == SignalPriority.LOW:
|
|
print(f"[FETCH] LOW priority signal saved without analysis: {new_signal.id}")
|
|
continue
|
|
|
|
# Queue for AI analysis (same logic as ingest_signal)
|
|
async def process_fetched_signal():
|
|
# RAG: Find relevant context from knowledge base
|
|
relevant_context = await llm_service.find_relevant_context(
|
|
signal_content=story['content'],
|
|
db_session=db,
|
|
top_k=3,
|
|
min_similarity=0.3
|
|
)
|
|
|
|
# Strategic Assessment with RAG context
|
|
strategy_result = llm_service.evaluate_with_voting(
|
|
signal_content=story['content'],
|
|
relevant_context=relevant_context,
|
|
impact_score=impact
|
|
)
|
|
|
|
is_strategic = strategy_result.get("is_strategic", False)
|
|
decision_proposal = strategy_result.get("decision_proposal", {})
|
|
confidence = strategy_result.get("confidence", 0.0)
|
|
|
|
# Re-calculate priority with confidence
|
|
final_priority = classify_signal_priority(impact, confidence)
|
|
new_signal.priority = final_priority.value
|
|
|
|
# Store strategic analysis
|
|
import json
|
|
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:
|
|
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)
|
|
tasks = strategy_result.get("tasks", [])
|
|
new_signal.tasks_generated = json.dumps(tasks)
|
|
new_signal.processed = True
|
|
|
|
await db.commit()
|
|
nonlocal processed_count
|
|
processed_count += 1
|
|
print(f"[FETCH] Signal analyzed: {new_signal.id}, strategic={is_strategic}")
|
|
|
|
# Process in background for MEDIUM/HIGH priority
|
|
if initial_priority in [SignalPriority.MEDIUM, SignalPriority.HIGH]:
|
|
background_tasks.add_task(process_fetched_signal)
|
|
else:
|
|
# CRITICAL: process immediately
|
|
await process_fetched_signal()
|
|
|
|
# Auto-stop Ollama after all analysis is complete to save resources
|
|
_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"
|
|
}
|
|
|
|
def _stop_ollama():
|
|
"""Stop Ollama service to save resources."""
|
|
import subprocess
|
|
import signal
|
|
import os
|
|
|
|
killed = False
|
|
|
|
# Method 1: Try to find and kill ollama serve processes
|
|
try:
|
|
result = subprocess.run(
|
|
["pgrep", "-f", "ollama"],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
if result.returncode == 0:
|
|
pids = result.stdout.strip().split('\n')
|
|
for pid in pids:
|
|
if pid:
|
|
try:
|
|
os.kill(int(pid), signal.SIGTERM)
|
|
killed = True
|
|
except ProcessLookupError:
|
|
pass
|
|
except Exception as e:
|
|
print(f"[AI] pgrep failed: {e}")
|
|
|
|
# Method 2: Fallback to pkill -9 (force kill)
|
|
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")
|
|
|
|
@app.get("/experiments/")
|
|
async def list_experiments(db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Experiment))
|
|
experiments = result.scalars().all()
|
|
return experiments
|
|
|
|
@app.post("/experiments/")
|
|
async def create_experiment(experiment: ExperimentCreate, db: AsyncSession = Depends(get_db)):
|
|
new_experiment = Experiment(
|
|
title=experiment.title,
|
|
hypothesis=experiment.hypothesis,
|
|
target_metric=experiment.target_metric,
|
|
linked_context_nodes=experiment.linked_context_nodes,
|
|
status="running" # Auto-start for now
|
|
)
|
|
db.add(new_experiment)
|
|
await db.commit()
|
|
await db.refresh(new_experiment)
|
|
return new_experiment
|
|
|
|
@app.patch("/experiments/{experiment_id}")
|
|
async def update_experiment_score(experiment_id: uuid.UUID, update: ExperimentUpdate, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(Experiment).where(Experiment.id == experiment_id))
|
|
experiment = result.scalars().first()
|
|
if not experiment:
|
|
raise HTTPException(status_code=404, detail="Experiment not found")
|
|
|
|
experiment.current_score = update.current_score
|
|
|
|
# Auto-update status based on score (Simple Logic)
|
|
# In a real scenario, this threshold might be dynamic or configured per experiment
|
|
if update.current_score >= 0.9:
|
|
experiment.status = "success"
|
|
elif update.current_score < 0.5:
|
|
experiment.status = "failed"
|
|
else:
|
|
experiment.status = "running"
|
|
|
|
await db.commit()
|
|
await db.refresh(experiment)
|
|
return experiment
|
|
|
|
@app.get("/context_nodes/")
|
|
async def list_context_nodes(db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(select(ContextNode))
|
|
nodes = result.scalars().all()
|
|
# Simplified serialization to avoid Vector issues
|
|
return [{
|
|
"id": node.id,
|
|
"content": node.content,
|
|
"status": node.status,
|
|
"version": node.version,
|
|
"has_embedding": node.embedding is not None,
|
|
"ai_insights": node.ai_insights
|
|
} for node in nodes]
|
|
|
|
@app.post("/context_nodes/")
|
|
async def create_context_node(node: ContextNodeCreate, db: AsyncSession = Depends(get_db)):
|
|
# Generate Embedding using Ollama
|
|
embedding = llm_service.get_embedding(node.content)
|
|
|
|
# AI Insight Extraction
|
|
insights = llm_service.extract_context_insights(node.content)
|
|
|
|
new_node = ContextNode(
|
|
content=node.content,
|
|
embedding=embedding if embedding else None,
|
|
ai_insights=insights
|
|
)
|
|
db.add(new_node)
|
|
await db.commit()
|
|
await db.refresh(new_node)
|
|
|
|
# Return a simplified response to avoid serialization issues with Vector type
|
|
return {
|
|
"id": new_node.id,
|
|
"content": new_node.content,
|
|
"status": new_node.status,
|
|
"version": new_node.version,
|
|
"has_embedding": new_node.embedding is not None,
|
|
"ai_insights": new_node.ai_insights
|
|
}
|
|
|
|
@app.post("/tasks/breakdown", response_model=TaskBreakdownResponse)
|
|
async def breakdown_tasks(request: TaskBreakdownRequest):
|
|
"""
|
|
AI Task Breakdown Engine.
|
|
Receives a natural language description and returns a list of technical tasks.
|
|
If sync_to_plane=True, it will create issues in Plane (simulated if no API key).
|
|
"""
|
|
tasks = llm_service.generate_task_breakdown(request.description)
|
|
if not tasks:
|
|
raise HTTPException(status_code=500, detail="Failed to generate task breakdown")
|
|
|
|
sync_result = None
|
|
if request.sync_to_plane:
|
|
sync_result = await plane_service.sync_tasks(tasks)
|
|
|
|
return {"tasks": tasks, "sync_result": sync_result}
|
|
|
|
# Feedback endpoints
|
|
class SignalFeedbackCreate(BaseModel):
|
|
signal_id: uuid.UUID
|
|
was_strategic: bool # User's actual judgment
|
|
reason: str = "" # Optional explanation
|
|
user_id: str = "" # Optional user identifier
|
|
|
|
class SignalFeedbackResponse(BaseModel):
|
|
id: uuid.UUID
|
|
signal_id: uuid.UUID
|
|
was_strategic: bool
|
|
ai_predicted_strategic: bool
|
|
ai_confidence: float
|
|
is_correct: bool # Whether AI prediction matched user feedback
|
|
|
|
@app.post("/signals/{signal_id}/feedback", response_model=SignalFeedbackResponse)
|
|
async def submit_signal_feedback(
|
|
signal_id: uuid.UUID,
|
|
feedback: SignalFeedbackCreate,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Submit user feedback on AI strategic analysis.
|
|
This helps the system learn from corrections.
|
|
"""
|
|
# 1. Verify signal exists
|
|
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")
|
|
|
|
# 2. Extract AI prediction from strategic_analysis
|
|
ai_predicted_strategic = False
|
|
ai_confidence = 0.0
|
|
|
|
if signal.strategic_analysis:
|
|
try:
|
|
import json
|
|
analysis = json.loads(signal.strategic_analysis)
|
|
decision_proposal = analysis.get("decision_proposal", {})
|
|
# Try different ways the prediction might be stored
|
|
ai_predicted_strategic = analysis.get("is_strategic", signal.impact_score >= 0.9)
|
|
ai_confidence = analysis.get("confidence", 0.0)
|
|
except:
|
|
# Fallback to impact_score
|
|
ai_predicted_strategic = signal.impact_score >= 0.9
|
|
|
|
# 3. Create feedback record
|
|
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)
|
|
|
|
# 4. Calculate if AI was correct
|
|
is_correct = ai_predicted_strategic == feedback.was_strategic
|
|
|
|
# 5. Log for monitoring
|
|
print(f"[FEEDBACK] Signal {signal_id}: AI predicted {ai_predicted_strategic}, user says {feedback.was_strategic} - {'✓ Correct' if is_correct else '✗ Wrong'}")
|
|
if feedback.reason:
|
|
print(f"[FEEDBACK] Reason: {feedback.reason}")
|
|
|
|
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
|
|
)
|
|
|
|
@app.get("/signals/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse])
|
|
async def get_signal_feedbacks(
|
|
signal_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Get all feedback for a specific signal.
|
|
"""
|
|
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
|
|
]
|
|
|
|
@app.get("/feedbacks/stats")
|
|
async def get_feedback_stats(db: AsyncSession = Depends(get_db)):
|
|
"""
|
|
Get overall feedback statistics for monitoring AI performance.
|
|
"""
|
|
result = await db.execute(select(SignalFeedback))
|
|
all_feedbacks = result.scalars().all()
|
|
|
|
total = len(all_feedbacks)
|
|
if total == 0:
|
|
return {
|
|
"total_feedbacks": 0,
|
|
"accuracy": 0.0,
|
|
"true_positives": 0,
|
|
"false_positives": 0,
|
|
"true_negatives": 0,
|
|
"false_negatives": 0
|
|
}
|
|
|
|
correct = sum(1 for f in all_feedbacks if f.ai_predicted_strategic == f.was_strategic)
|
|
true_positives = sum(1 for f in all_feedbacks if f.ai_predicted_strategic and f.was_strategic)
|
|
false_positives = sum(1 for f in all_feedbacks if f.ai_predicted_strategic and not f.was_strategic)
|
|
true_negatives = sum(1 for f in all_feedbacks if not f.ai_predicted_strategic and not f.was_strategic)
|
|
false_negatives = sum(1 for f in all_feedbacks if not f.ai_predicted_strategic and f.was_strategic)
|
|
|
|
return {
|
|
"total_feedbacks": total,
|
|
"accuracy": correct / total,
|
|
"true_positives": true_positives,
|
|
"false_positives": false_positives,
|
|
"true_negatives": true_negatives,
|
|
"false_negatives": false_negatives
|
|
}
|
|
|
|
@app.get("/ai/status")
|
|
async def get_ai_status():
|
|
"""
|
|
Check if Ollama AI service is running and available.
|
|
"""
|
|
import subprocess
|
|
import json
|
|
|
|
try:
|
|
# Use curl to check Ollama status
|
|
result = subprocess.run(
|
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://127.0.0.1:11434/api/tags"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=3
|
|
)
|
|
|
|
if result.stdout.strip() == "200":
|
|
# Try to get model list
|
|
result2 = subprocess.run(
|
|
["curl", "-s", "http://127.0.0.1:11434/api/tags"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=3
|
|
)
|
|
if result2.returncode == 0:
|
|
data = json.loads(result2.stdout)
|
|
models = data.get("models", [])
|
|
model_names = [m.get("name", "unknown") for m in models]
|
|
return {
|
|
"status": "ready",
|
|
"ollama_running": True,
|
|
"available_models": model_names,
|
|
"model_count": len(model_names)
|
|
}
|
|
except Exception as e:
|
|
print(f"[AI STATUS] Error: {e}")
|
|
|
|
return {
|
|
"status": "not_ready",
|
|
"ollama_running": False,
|
|
"available_models": [],
|
|
"model_count": 0,
|
|
"error": "Ollama service not available. Start with: ollama serve"
|
|
}
|
|
|
|
@app.post("/ai/stop")
|
|
async def stop_ai_service():
|
|
"""
|
|
Manually stop Ollama service to save resources and reduce heat.
|
|
Tries multiple methods to ensure all Ollama processes are terminated.
|
|
"""
|
|
import subprocess
|
|
killed_any = False
|
|
errors = []
|
|
|
|
# Method 1: Kill ollama serve process (the actual model server)
|
|
try:
|
|
result = subprocess.run(
|
|
["pkill", "-9", "-f", "ollama serve"],
|
|
capture_output=True,
|
|
timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
print("[AI] Killed 'ollama serve' process")
|
|
killed_any = True
|
|
except Exception as e:
|
|
errors.append(f"pkill ollama serve: {e}")
|
|
|
|
# Method 2: Kill Ollama.app main process (macOS)
|
|
try:
|
|
result = subprocess.run(
|
|
["pkill", "-9", "-f", "Ollama.app"],
|
|
capture_output=True,
|
|
timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
print("[AI] Killed Ollama.app process")
|
|
killed_any = True
|
|
except Exception as e:
|
|
errors.append(f"pkill Ollama.app: {e}")
|
|
|
|
# Method 3: Fallback - try killall (macOS/Linux)
|
|
if not killed_any:
|
|
try:
|
|
result = subprocess.run(
|
|
["killall", "-9", "ollama"],
|
|
capture_output=True,
|
|
timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
print("[AI] Killed ollama via killall")
|
|
killed_any = True
|
|
except Exception as e:
|
|
errors.append(f"killall ollama: {e}")
|
|
|
|
# Method 4: Last resort - kill by port
|
|
try:
|
|
# Find process using port 11434
|
|
result = subprocess.run(
|
|
["lsof", "-ti", ":11434"],
|
|
capture_output=True,
|
|
timeout=5
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
pid = result.stdout.strip().decode()
|
|
subprocess.run(["kill", "-9", pid], capture_output=True, timeout=5)
|
|
print(f"[AI] Killed process {pid} using port 11434")
|
|
killed_any = True
|
|
except Exception as e:
|
|
# Port method is optional, don't record as error
|
|
pass
|
|
|
|
if killed_any:
|
|
return {
|
|
"status": "stopped",
|
|
"message": "Ollama service stopped successfully"
|
|
}
|
|
elif errors:
|
|
return {
|
|
"status": "error",
|
|
"message": f"Failed to stop Ollama: {'; '.join(errors)}"
|
|
}
|
|
else:
|
|
return {
|
|
"status": "not_running",
|
|
"message": "Ollama was not running"
|
|
}
|
|
|
|
|
|
# LLM 配置管理
|
|
class LLMConfig(BaseModel):
|
|
provider: str = "deepseek" # ollama, deepseek, openai, qwen
|
|
api_key: Optional[str] = None
|
|
deepseek_api_key: Optional[str] = None
|
|
openai_api_key: Optional[str] = None
|
|
qwen_api_key: Optional[str] = None
|
|
|
|
|
|
@app.get("/ai/config")
|
|
async def get_ai_config():
|
|
"""
|
|
获取当前 LLM 配置
|
|
"""
|
|
from dotenv import load_dotenv
|
|
import os
|
|
|
|
# 重新加载环境变量
|
|
env_path = Path(__file__).parent.parent / ".env"
|
|
load_dotenv(env_path)
|
|
|
|
provider = os.getenv("LLM_PROVIDER", "ollama")
|
|
|
|
return {
|
|
"provider": provider,
|
|
"ollama_host": os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434"),
|
|
"has_deepseek_key": bool(os.getenv("DEEPSEEK_API_KEY")),
|
|
"has_openai_key": bool(os.getenv("OPENAI_API_KEY")),
|
|
"has_qwen_key": bool(os.getenv("QWEN_API_KEY")),
|
|
}
|
|
|
|
|
|
@app.post("/ai/config")
|
|
async def update_ai_config(config: LLMConfig):
|
|
"""
|
|
更新 LLM 配置(需要重启后端生效)
|
|
"""
|
|
from dotenv import load_dotenv
|
|
import os
|
|
|
|
env_path = Path(__file__).parent.parent / ".env"
|
|
|
|
# 读取现有 .env 文件
|
|
env_vars = {}
|
|
if env_path.exists():
|
|
load_dotenv(env_path)
|
|
# 保留现有配置
|
|
for key, value in os.environ.items():
|
|
if key.startswith(("DEEPSEEK_", "OPENAI_", "QWEN_", "OLLAMA_", "LLM_")):
|
|
env_vars[key] = value
|
|
|
|
# 更新配置
|
|
env_vars["LLM_PROVIDER"] = config.provider
|
|
|
|
# 保存 API 密钥
|
|
if config.deepseek_api_key:
|
|
env_vars["DEEPSEEK_API_KEY"] = config.deepseek_api_key
|
|
if config.openai_api_key:
|
|
env_vars["OPENAI_API_KEY"] = config.openai_api_key
|
|
if config.qwen_api_key:
|
|
env_vars["QWEN_API_KEY"] = config.qwen_api_key
|
|
|
|
# 写入 .env 文件
|
|
with open(env_path, "w") as f:
|
|
for key, value in env_vars.items():
|
|
# 只保存 LLM 相关的环境变量
|
|
if key.startswith(("DEEPSEEK_", "OPENAI_", "QWEN_", "OLLAMA_", "LLM_")):
|
|
f.write(f"{key}={value}\n")
|
|
|
|
print(f"[AI] LLM 配置已更新: provider={config.provider}")
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "配置已保存,请重启后端服务生效",
|
|
"provider": config.provider,
|
|
"requires_restart": True
|
|
}
|