File size: 9,994 Bytes
7c82621 f63c27a 7c82621 4d32f96 7c82621 4d32f96 7c82621 4d32f96 7c82621 4d32f96 7c82621 e5afd13 7c82621 e5afd13 7c82621 d24c184 7c82621 d24c184 be04c5f d24c184 be04c5f d24c184 be04c5f d24c184 be04c5f d24c184 be04c5f d24c184 be04c5f d24c184 be04c5f 7c82621 d24c184 7c82621 d24c184 7c82621 e5afd13 7c82621 edd4216 7c82621 e5afd13 7c82621 4d32f96 7c82621 edd4216 7c82621 edd4216 7c82621 4d32f96 7c82621 4d32f96 7c82621 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | """
nodes.py — LangGraph 各節點實作
每個節點都是純函數:(state) → dict(partial state update)
"""
import os
import time
import uuid
import json
import httpx
import asyncio
from typing import Optional
# 服務 URL(由環境變數設定,在 HuggingFace Spaces 間通信)
CREW_SERVICE_URL = os.getenv("CREW_SERVICE_URL", "http://localhost:7861")
# 分類用的 LLM
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_groq import ChatGroq
def _get_classifier_llm():
"""用最快速的 LLM 做意圖分類"""
groq_key = os.getenv("GROQ_KEY_1") or os.getenv("GROQ_KEY")
if groq_key:
return ChatGroq(model="llama-3.1-8b-instant", groq_api_key=groq_key, temperature=0)
google_key = os.getenv("GOOGLE_API_KEY_1") or os.getenv("GOOGLE_API_KEY")
if google_key:
return ChatGoogleGenerativeAI(
model="gemini-1.5-flash",
google_api_key=google_key,
temperature=0,
convert_system_message_to_human=True,
)
raise RuntimeError("No LLM keys available for classifier")
CLASSIFIER_LLM = None
def _classifier():
global CLASSIFIER_LLM
if CLASSIFIER_LLM is None:
CLASSIFIER_LLM = _get_classifier_llm()
return CLASSIFIER_LLM
# ── 節點實作 ──────────────────────────────────────────────────────────────────
def classify_intent(state: dict) -> dict:
"""
節點 1:分析用戶輸入,決定意圖和部門
"""
user_input = state["user_input"]
prompt = f"""
你是 AI 公司的任務分配系統。分析以下用戶輸入,回傳 JSON。
用戶輸入:{user_input}
可用意圖(按優先級判斷):
- code: 需要【寫程式碼、開發、建立、實作】任何軟體、網站、應用、腳本、工具、API、系統。
例如:寫網站、建立 API、開發 App、寫腳本、實作功能、建資料庫
- review: 需要 code review、審查程式碼、檢查 bug、技術審查
- strategy: 需要制定【商業策略、OKR、市場計劃】,純規劃不涉及實作
- prd: 需要撰寫產品需求文件、功能規格書
- marketing: 需要行銷文案、社群貼文、部落格、郵件
- research: 需要市場研究、競品分析、行業報告
- sales: 需要銷售文案、冷郵件、客戶開發
- chat: 一般問答
判斷規則:
- 只要提到「寫」、「建」、「開發」、「實作」、「製作」加上任何軟體/網站/工具,一律是 code
- 「網站」、「App」、「API」、「腳本」相關需求,一律是 code
- 只有純商業規劃、不涉及任何實作,才是 strategy
只回傳 JSON,不要其他文字:
{{"intent": "...", "department": "...", "summary": "30 字內的任務摘要"}}
"""
try:
result = _classifier().invoke(prompt)
content = result.content if hasattr(result, "content") else str(result)
# 清理可能的 markdown code block
content = content.strip().strip("```json").strip("```").strip()
parsed = json.loads(content)
return {
"intent": parsed.get("intent", "chat"),
"department": parsed.get("department", "management"),
"messages": [{"role": "system", "content": f"Intent classified: {parsed}"}],
}
except Exception as e:
return {
"intent": "chat",
"department": "management",
"error": f"Classification failed: {e}",
}
def route_to_department(state: dict) -> dict:
"""節點 2:確認路由(可加業務邏輯)"""
return {"job_id": str(uuid.uuid4())[:8], "retry_count": 0}
def _post_to_crew(endpoint: str, payload: dict, job_id: str) -> dict:
try:
resp = httpx.post(
f"{CREW_SERVICE_URL}/{endpoint}",
json={**payload, "job_id": job_id},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
returned_job_id = data.get("job_id", job_id)
print(f"[crew] submitted to /{endpoint}, job_id: {returned_job_id}")
return {"job_status": "queued", "job_id": returned_job_id}
except Exception as e:
print(f"[crew] error posting to /{endpoint}: {e}")
return {"error": str(e), "job_status": "error"}
def call_crew_strategy(state: dict) -> dict:
"""節點:呼叫 CrewAI CEO 制定策略"""
return _post_to_crew(
"strategy",
{"objective": state["user_input"]},
state["job_id"],
)
def call_crew_prd(state: dict) -> dict:
"""節點:呼叫 CrewAI PM 撰寫 PRD"""
return _post_to_crew(
"product-spec",
{"feature_request": state["user_input"]},
state["job_id"],
)
def call_crew_marketing(state: dict) -> dict:
"""節點:呼叫 CrewAI 市場部"""
# 試著從輸入推斷 content_type
user_input = state["user_input"].lower()
content_type = "blog"
for ct in ["twitter", "linkedin", "email", "newsletter", "blog"]:
if ct in user_input:
content_type = ct
break
return _post_to_crew(
"marketing",
{"topic": state["user_input"], "content_type": content_type},
state["job_id"],
)
def call_crew_research(state: dict) -> dict:
"""節點:呼叫 CrewAI 分析師做市場研究"""
return _post_to_crew(
"research",
{"query": state["user_input"]},
state["job_id"],
)
def call_code_generation(state: dict) -> dict:
"""直接用 Groq 生成程式碼,不依賴 OpenHands"""
from langchain_groq import ChatGroq
groq_key = os.getenv("GROQ_KEY_1") or os.getenv("GROQ_KEY_2")
if not groq_key:
return {"job_status": "error", "error": "No Groq key available"}
llm = ChatGroq(
model="llama-3.3-70b-versatile",
groq_api_key=groq_key,
temperature=0,
)
intent = state.get("intent", "code")
user_input = state["user_input"]
if intent == "review":
prompt = f"""You are a senior software engineer. Review the following code.
Code:
{user_input}
Output in English only:
1. Issue list (severity: High/Medium/Low)
2. Improvement suggestions
3. Fixed complete code
4. Security considerations
Use Markdown format."""
else:
prompt = f"""You are a senior software engineer. Complete the following task.
Task: {user_input}
STRICT RULES:
1. ALL code comments, variable names, function names, and output text must be in ENGLISH only. No Chinese characters anywhere in the code.
2. ALL content inside the code (text, labels, placeholders, error messages) must be in ENGLISH.
3. Use ONLY standard library or the most common packages. Do NOT use any framework that requires separate installation (no Astro, no Next.js, no Nuxt, no SvelteKit) unless the user explicitly requested it by name.
4. If building a website, use plain HTML + CSS + JavaScript in a single file, or use a minimal setup (e.g. Vite + vanilla JS). Output must be immediately runnable with minimal setup.
5. If a framework is absolutely necessary, provide the COMPLETE setup commands and explain every step.
Output format:
## Setup
(exact commands to run, copy-paste ready)
## File Structure
(list of files to create)
## Code
(complete code for each file, with English comments)
## How to Run
(exact run command)
Use Markdown format. Output in English only."""
try:
result = llm.invoke(prompt)
return {
"job_status": "done",
"openhands_result": result.content,
}
except Exception as e:
return {"job_status": "error", "error": str(e)}
def wait_for_job(state: dict) -> dict:
job_id = state.get("job_id")
retry = state.get("retry_count", 0)
MAX_RETRIES = 100
POLL_INTERVAL = 5
if not job_id or job_id == "None":
return {"job_status": "error", "error": "job_id is None, task was not submitted correctly"}
if retry >= MAX_RETRIES:
return {"job_status": "error", "error": "Timeout: job took too long"}
time.sleep(POLL_INTERVAL)
try:
resp = httpx.get(
f"{CREW_SERVICE_URL}/jobs/{job_id}",
timeout=10,
)
resp.raise_for_status()
data = resp.json()
status = data.get("status", "running")
print(f"[wait_job] CrewAI job {job_id} status: {status}")
if status == "done":
return {"job_status": "done", "crew_result": data.get("result", "")}
elif status == "error":
return {
"job_status": "error",
"error": data.get("error", "CrewAI unknown error"),
}
return {"job_status": "running", "retry_count": retry + 1}
except httpx.HTTPStatusError as e:
print(f"[wait_job] HTTP error: {e.response.status_code} - {e.response.text}")
return {"job_status": "running", "retry_count": retry + 1}
except Exception as e:
print(f"[wait_job] Exception: {type(e).__name__}: {e}")
return {"job_status": "running", "retry_count": retry + 1}
def format_response(state: dict) -> dict:
crew_result = state.get("crew_result") or ""
openhands_result = state.get("openhands_result") or ""
if crew_result:
final = crew_result
elif openhands_result:
final = openhands_result
else:
final = "任務已完成,但沒有收到回應內容。"
return {
"final_response": final,
"messages": [{"role": "assistant", "content": final}],
}
def handle_error(state: dict) -> dict:
"""節點:錯誤處理"""
error = state.get("error", "未知錯誤")
response = (
f"⚠️ 任務執行遇到問題:\n\n```\n{error}\n```\n\n"
"請稍後重試,或簡化您的請求。"
)
return {
"final_response": response,
"messages": [{"role": "assistant", "content": response}],
}
|