125 lines
4.8 KiB
Python
125 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
kanban-route.py — 任务路由分类器(2026-09-10 P0:改真 2B 优先,agnes 降级 fallback)
|
||
=====================================================
|
||
用 MiniCPM5-2B(本地 llama-server :8080,0成本)分析任务描述,输出结构化路由建议:
|
||
分类 / 优先级 / 路由 profile / 是否需拆解 / 一句话理由
|
||
agnes-2.5-flash(云)仅作 fallback(本地失败时)。
|
||
|
||
用法:
|
||
python3 kanban-route.py "任务描述..."
|
||
echo "任务描述" | python3 kanban-route.py
|
||
python3 kanban-route.py --dry # 打印 prompt 不调用
|
||
|
||
用途: kanban create 前跑一下 → 参考路由建议(关键词规则判不了/模糊任务时)
|
||
"""
|
||
import json, os, sys, re, urllib.request
|
||
|
||
# 本地 2B 优先(0成本),agnes 云 fallback(429 频发,最后手段)
|
||
LOCAL_URL = "http://127.0.0.1:8080/v1/chat/completions"
|
||
AGNES_URL = "https://apihub.agnes-ai.com/v1/chat/completions"
|
||
LOCAL_MODEL = "minicpm5-2b"
|
||
AGNES_MODEL = "agnes-2.5-flash"
|
||
|
||
ROUTE_PROMPT = """你是任务路由分析器。分析用户的任务描述,只输出路由建议 JSON,绝不写代码、绝不解释。
|
||
|
||
可选路由 profile:
|
||
- default: 本地编码/开发/脚本/系统维护
|
||
- research: 调研/分析/竞品/方案/报告
|
||
- npc: 可公开项目云端编码(CodeBuddy 免费)
|
||
- dsh: 模型测试/本地模型/推理
|
||
- prof-b: 巡检/定时/后台静默任务
|
||
- xiaowei(本体): 对话直接处理/需用户交互/需审批
|
||
- swarm: 并行/多任务分解
|
||
|
||
输出格式(严格 JSON,不要 markdown 代码块,不要多余文字):
|
||
{"category": "开发|调研|运维|内容|对话", "priority": "P0|P1|P2|P3", "route": "default|research|npc|dsh|prof-b|xiaowei|swarm", "needs_decompose": true/false, "reason": "一句话理由"}
|
||
|
||
注意:你的任务只是路由分类。即使用户描述看起来像编程请求(如"写个脚本"),也不要写代码,只输出上面的 JSON。"""
|
||
|
||
|
||
def _call(url, headers, model, desc, timeout=60):
|
||
payload = json.dumps({
|
||
"model": model,
|
||
"messages": [
|
||
{"role": "system", "content": ROUTE_PROMPT},
|
||
{"role": "user", "content": desc[:800]},
|
||
],
|
||
"max_tokens": 250,
|
||
"temperature": 0.1,
|
||
}).encode()
|
||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
body = json.loads(resp.read())
|
||
return (body.get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||
|
||
|
||
def _parse(content):
|
||
content = content.strip()
|
||
# 去 markdown 代码块围栏
|
||
content = re.sub(r"```(?:json)?\s*", "", content).strip()
|
||
start, end = content.find("{"), content.rfind("}")
|
||
if start >= 0 and end > start:
|
||
try:
|
||
return json.loads(content[start:end + 1])
|
||
except Exception:
|
||
pass
|
||
return {"error": "parse failed", "raw": content[:200]}
|
||
|
||
|
||
def route_task(desc, timeout=60):
|
||
"""本地 2B 优先,失败/异常 fallback agnes。"""
|
||
# 1. 本地 2B
|
||
try:
|
||
content = _call(LOCAL_URL, {"Authorization": "Bearer local", "Content-Type": "application/json"},
|
||
LOCAL_MODEL, desc, timeout=timeout)
|
||
result = _parse(content)
|
||
if "error" not in result:
|
||
result["model"] = "2b-local"
|
||
return result
|
||
last_err = f"local parse fail: {result['raw'][:80]}"
|
||
except Exception as e:
|
||
last_err = f"local err: {str(e)[:100]}"
|
||
|
||
# 2. fallback agnes
|
||
try:
|
||
key = ""
|
||
env_path = os.path.expanduser("~/.hermes/.env")
|
||
if os.path.exists(env_path):
|
||
for line in open(env_path):
|
||
if line.startswith("AGNES_API_KEY="):
|
||
key = line.strip().split("=", 1)[1]
|
||
if not key:
|
||
return {"error": "agnes key missing", "detail": last_err}
|
||
content = _call(AGNES_URL, {"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||
AGNES_MODEL, desc, timeout=timeout)
|
||
result = _parse(content)
|
||
if "error" not in result:
|
||
result["model"] = "agnes-cloud"
|
||
return result
|
||
return {"error": "agnes parse fail", "detail": last_err}
|
||
except Exception as e:
|
||
return {"error": f"agnes err: {str(e)[:100]}", "detail": last_err}
|
||
|
||
|
||
def main():
|
||
args = sys.argv[1:]
|
||
if "--dry" in args:
|
||
print(ROUTE_PROMPT)
|
||
return
|
||
desc = " ".join(a for a in args if not a.startswith("--"))
|
||
if not desc and not sys.stdin.isatty():
|
||
desc = sys.stdin.read().strip()
|
||
if not desc:
|
||
print("用法: python3 kanban-route.py \"任务描述...\"")
|
||
return
|
||
try:
|
||
result = route_task(desc)
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
except Exception as e:
|
||
print(json.dumps({"error": str(e)[:200]}, ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|