729 lines
30 KiB
Python
729 lines
30 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
AI Worker 项目管理平台 - MVP
|
|
人派活 → AI 干活 → 人验收 最小闭环
|
|
"""
|
|
import os
|
|
import secrets
|
|
import functools
|
|
import json as _json
|
|
from flask import Flask, request, jsonify, session, send_from_directory
|
|
|
|
import config
|
|
import db
|
|
import engine
|
|
import llm_gateway
|
|
import rag
|
|
import notify
|
|
|
|
app = Flask(__name__, static_folder='static', static_url_path='')
|
|
app.secret_key = config.SECRET_KEY
|
|
db.init_db()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 鉴权(简单口令登录)
|
|
# ---------------------------------------------------------------------------
|
|
def auth_enabled():
|
|
return bool(config.AUTH_PASSWORD)
|
|
|
|
|
|
@app.route('/api/login', methods=['POST'])
|
|
def login():
|
|
data = request.get_json(force=True)
|
|
if not auth_enabled():
|
|
return jsonify({'ok': True})
|
|
if data.get('password') == config.AUTH_PASSWORD:
|
|
session['authed'] = True
|
|
return jsonify({'ok': True})
|
|
return jsonify({'ok': False, 'error': '口令错误'}), 401
|
|
|
|
|
|
@app.route('/api/logout', methods=['POST'])
|
|
def logout():
|
|
session.clear()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/me')
|
|
def me():
|
|
return jsonify({'ok': True, 'authed': not auth_enabled() or session.get('authed')})
|
|
|
|
|
|
def _auth_ok():
|
|
"""会话或 API Token 任一通过即可"""
|
|
if not auth_enabled():
|
|
return True
|
|
if session.get('authed'):
|
|
return True
|
|
hdr = request.headers.get('Authorization', '')
|
|
if hdr.startswith('Bearer '):
|
|
tok = hdr[7:].strip()
|
|
r = db.q('SELECT id FROM api_tokens WHERE token=?', (tok,), one=True)
|
|
if r:
|
|
db.w('UPDATE api_tokens SET last_used_at=? WHERE id=?', (db.now(), r['id']))
|
|
return True
|
|
return False
|
|
|
|
|
|
def require_auth(fn):
|
|
@functools.wraps(fn)
|
|
def wrapper(*args, **kwargs):
|
|
if not _auth_ok():
|
|
return jsonify({'ok': False, 'error': '未登录或 Token 无效'}), 401
|
|
return fn(*args, **kwargs)
|
|
return wrapper
|
|
|
|
|
|
@app.route('/api/health')
|
|
def health():
|
|
return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v1.0.0'})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 项目
|
|
# ---------------------------------------------------------------------------
|
|
@app.route('/api/projects', methods=['GET', 'POST'])
|
|
@require_auth
|
|
def projects():
|
|
if request.method == 'POST':
|
|
d = request.get_json(force=True)
|
|
pid = db.w(
|
|
'INSERT INTO projects (name, description, objective, acceptance_criteria, '
|
|
'status, budget_limit, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
|
(d.get('name', '').strip(), d.get('description', ''), d.get('objective', ''),
|
|
d.get('acceptance_criteria', ''), d.get('status', 'active'),
|
|
float(d.get('budget_limit') or 0), db.now(), db.now()))
|
|
return jsonify({'ok': True, 'id': pid})
|
|
rows = db.q('SELECT * FROM projects ORDER BY id DESC')
|
|
for r in rows:
|
|
r['task_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=?', (r['id'],))[0]['c']
|
|
r['done_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done"',
|
|
(r['id'],))[0]['c']
|
|
return jsonify({'ok': True, 'data': rows})
|
|
|
|
|
|
@app.route('/api/projects/<int:pid>', methods=['GET', 'PUT', 'DELETE'])
|
|
@require_auth
|
|
def project_detail(pid):
|
|
if request.method == 'GET':
|
|
p = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
|
if not p:
|
|
return jsonify({'ok': False, 'error': '项目不存在'}), 404
|
|
return jsonify({'ok': True, 'data': p})
|
|
if request.method == 'DELETE':
|
|
db.w('DELETE FROM tasks WHERE project_id=?', (pid,))
|
|
db.w('DELETE FROM cost_records WHERE project_id=?', (pid,))
|
|
db.w('DELETE FROM projects WHERE id=?', (pid,))
|
|
return jsonify({'ok': True})
|
|
d = request.get_json(force=True)
|
|
fields = ['name', 'description', 'objective', 'acceptance_criteria', 'status', 'budget_limit']
|
|
sets, args = [], []
|
|
for f in fields:
|
|
if f in d:
|
|
sets.append(f'{f}=?')
|
|
args.append(d[f])
|
|
if sets:
|
|
args.append(db.now())
|
|
db.w(f'UPDATE projects SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, pid))
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Worker
|
|
# ---------------------------------------------------------------------------
|
|
@app.route('/api/workers', methods=['GET', 'POST'])
|
|
@require_auth
|
|
def workers():
|
|
if request.method == 'POST':
|
|
d = request.get_json(force=True)
|
|
wid = db.w(
|
|
'INSERT INTO workers (name, description, provider, model, base_url, api_key, '
|
|
'system_prompt, temperature, max_tokens, task_cost_limit, monthly_cost_limit, '
|
|
'status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
|
(d.get('name', '').strip(), d.get('description', ''), d.get('provider', ''),
|
|
d.get('model', ''), d.get('base_url', ''), d.get('api_key', ''),
|
|
d.get('system_prompt', ''), float(d.get('temperature', 0.7)),
|
|
int(d.get('max_tokens', 2000)), float(d.get('task_cost_limit') or 0),
|
|
float(d.get('monthly_cost_limit') or 0), d.get('status', 'enabled'),
|
|
db.now(), db.now()))
|
|
return jsonify({'ok': True, 'id': wid})
|
|
rows = db.q('SELECT * FROM workers ORDER BY id DESC')
|
|
for r in rows:
|
|
r['month_cost'] = round(db.monthly_worker_cost(r['id']), 6)
|
|
return jsonify({'ok': True, 'data': rows})
|
|
|
|
|
|
@app.route('/api/workers/<int:wid>', methods=['GET', 'PUT', 'DELETE'])
|
|
@require_auth
|
|
def worker_detail(wid):
|
|
if request.method == 'GET':
|
|
w = db.q('SELECT * FROM workers WHERE id=?', (wid,), one=True)
|
|
return jsonify({'ok': True, 'data': w}) if w else (jsonify({'ok': False, 'error': '不存在'}), 404)
|
|
if request.method == 'DELETE':
|
|
db.w('UPDATE tasks SET worker_id=NULL WHERE worker_id=?', (wid,))
|
|
db.w('DELETE FROM workers WHERE id=?', (wid,))
|
|
return jsonify({'ok': True})
|
|
d = request.get_json(force=True)
|
|
fields = ['name', 'description', 'provider', 'model', 'base_url', 'api_key',
|
|
'system_prompt', 'temperature', 'max_tokens', 'task_cost_limit',
|
|
'monthly_cost_limit', 'status']
|
|
sets, args = [], []
|
|
for f in fields:
|
|
if f in d:
|
|
sets.append(f'{f}=?')
|
|
args.append(d[f])
|
|
if sets:
|
|
args.append(db.now())
|
|
db.w(f'UPDATE workers SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, wid))
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/workers/<int:wid>/test', methods=['POST'])
|
|
@require_auth
|
|
def worker_test(wid):
|
|
w = db.q('SELECT * FROM workers WHERE id=?', (wid,), one=True)
|
|
if not w:
|
|
return jsonify({'ok': False, 'error': '不存在'}), 404
|
|
try:
|
|
r = llm_gateway.test_connection(w['provider'], w['model'],
|
|
base_url=w['base_url'] or None,
|
|
api_key=w['api_key'] or None)
|
|
return jsonify({'ok': True, 'data': r})
|
|
except Exception as e:
|
|
return jsonify({'ok': False, 'error': str(e)})
|
|
|
|
|
|
@app.route('/api/providers')
|
|
@require_auth
|
|
def providers():
|
|
data = []
|
|
for k, v in config.PROVIDERS.items():
|
|
data.append({
|
|
'id': k, 'name': v['name'], 'base_url': v['base_url'],
|
|
'has_key': bool(v['api_key']),
|
|
'models': [m for m in config.MODEL_PRICING.keys() if m.startswith(
|
|
{'doubao': 'doubao', 'deepseek': 'deepseek', 'openai': 'gpt',
|
|
'qwen': 'qwen', 'vllm': ''}.get(k, '__none__'))],
|
|
})
|
|
return jsonify({'ok': True, 'data': data})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 任务
|
|
# ---------------------------------------------------------------------------
|
|
@app.route('/api/tasks', methods=['GET', 'POST'])
|
|
@require_auth
|
|
def tasks():
|
|
if request.method == 'POST':
|
|
d = request.get_json(force=True)
|
|
tid = db.w(
|
|
'INSERT INTO tasks (project_id, worker_id, title, description, priority, '
|
|
'review_required, deadline, depends_on, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
|
(d.get('project_id'), d.get('worker_id'), d.get('title', '').strip(),
|
|
d.get('description', ''), d.get('priority', 'medium'),
|
|
1 if d.get('review_required', True) else 0, d.get('deadline', ''),
|
|
_json.dumps(d.get('depends_on') or []), db.now(), db.now()))
|
|
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
|
(tid, 'info', f'任务创建:{d.get("title","")}', db.now()))
|
|
return jsonify({'ok': True, 'id': tid})
|
|
pid = request.args.get('project_id')
|
|
if pid:
|
|
rows = db.q('SELECT * FROM tasks WHERE project_id=? ORDER BY id DESC', (int(pid),))
|
|
else:
|
|
rows = db.q('SELECT * FROM tasks ORDER BY id DESC LIMIT 200')
|
|
return jsonify({'ok': True, 'data': [db.serialize_task(t) for t in rows]})
|
|
|
|
|
|
@app.route('/api/tasks/<int:tid>', methods=['GET', 'PUT', 'DELETE'])
|
|
@require_auth
|
|
def task_detail(tid):
|
|
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
|
if not t:
|
|
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
|
if request.method == 'GET':
|
|
t = db.serialize_task(t)
|
|
t['logs'] = db.q('SELECT * FROM task_logs WHERE task_id=? ORDER BY id', (tid,))
|
|
t['costs'] = db.q('SELECT * FROM cost_records WHERE task_id=? ORDER BY id', (tid,))
|
|
if t['worker_id']:
|
|
t['worker'] = db.q('SELECT id,name,provider,model FROM workers WHERE id=?',
|
|
(t['worker_id'],), one=True)
|
|
return jsonify({'ok': True, 'data': t})
|
|
if request.method == 'DELETE':
|
|
db.w('DELETE FROM task_logs WHERE task_id=?', (tid,))
|
|
db.w('DELETE FROM cost_records WHERE task_id=?', (tid,))
|
|
db.w('DELETE FROM tasks WHERE id=?', (tid,))
|
|
return jsonify({'ok': True})
|
|
d = request.get_json(force=True)
|
|
# 只允许在非运行中修改基础字段
|
|
if t['status'] == 'running':
|
|
return jsonify({'ok': False, 'error': '任务执行中,禁止修改'}), 400
|
|
fields = ['title', 'description', 'worker_id', 'priority', 'review_required',
|
|
'deadline', 'status']
|
|
sets, args = [], []
|
|
for f in fields:
|
|
if f in d:
|
|
sets.append(f'{f}=?')
|
|
args.append(d[f])
|
|
if 'depends_on' in d:
|
|
sets.append('depends_on=?')
|
|
args.append(_json.dumps(d['depends_on'] or []))
|
|
if sets:
|
|
args.append(db.now())
|
|
db.w(f'UPDATE tasks SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, tid))
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/tasks/<int:tid>/run', methods=['POST'])
|
|
@require_auth
|
|
def task_run(tid):
|
|
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
|
if not t:
|
|
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
|
if t['status'] == 'running':
|
|
return jsonify({'ok': False, 'error': '任务已在执行中'}), 400
|
|
ok, blockers = engine.check_dependencies(t)
|
|
if not ok:
|
|
return jsonify({'ok': False, 'error': '前置任务未完成:' + '、'.join(blockers)}), 400
|
|
if engine.runner.submit(tid):
|
|
return jsonify({'ok': True})
|
|
return jsonify({'ok': False, 'error': '任务已在执行中'}), 400
|
|
|
|
|
|
@app.route('/api/tasks/<int:tid>/cancel', methods=['POST'])
|
|
@require_auth
|
|
def task_cancel(tid):
|
|
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
|
if not t:
|
|
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
|
if t['status'] != 'running':
|
|
return jsonify({'ok': False, 'error': '仅执行中的任务可取消'}), 400
|
|
# MVP:标记取消(线程无法强杀,完成后会回到 review;这里直接置 cancelled 并忽略结果)
|
|
db.w('UPDATE tasks SET status="cancelled", updated_at=?, finished_at=? WHERE id=?',
|
|
(db.now(), db.now(), tid))
|
|
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
|
(tid, 'warn', '任务已由人工取消(引擎线程将在后台自然结束)', db.now()))
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/tasks/<int:tid>/review', methods=['POST'])
|
|
@require_auth
|
|
def task_review(tid):
|
|
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
|
if not t:
|
|
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
|
if t['status'] != 'review':
|
|
return jsonify({'ok': False, 'error': '仅待审核状态可审核'}), 400
|
|
d = request.get_json(force=True)
|
|
action = d.get('action')
|
|
reason = (d.get('reason') or '').strip()
|
|
if action == 'approve':
|
|
db.w('UPDATE tasks SET status="done", updated_at=? WHERE id=?', (db.now(), tid))
|
|
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
|
(tid, 'success', '✅ 人工审核通过,任务完成', db.now()))
|
|
# DAG:审核放行等同完成,触发下游就绪任务
|
|
fresh = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
|
for t in engine._trigger_downstream(fresh):
|
|
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
|
(t['id'], 'info', f'🔗 前置任务「{fresh["title"]}」已验收完成,自动触发执行', db.now()))
|
|
return jsonify({'ok': True})
|
|
if action == 'reject':
|
|
if not reason:
|
|
return jsonify({'ok': False, 'error': '打回必须填写原因'}), 400
|
|
db.w('UPDATE tasks SET status="todo", rejection_count=rejection_count+1, '
|
|
'output_text="", updated_at=? WHERE id=?', (db.now(), tid))
|
|
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
|
(tid, 'warn', f'⛔ 人工打回:{reason}(等待返工重跑)', db.now()))
|
|
return jsonify({'ok': True})
|
|
return jsonify({'ok': False, 'error': '未知操作'}), 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 报表 / 统计
|
|
# ---------------------------------------------------------------------------
|
|
@app.route('/api/reports/cost')
|
|
@require_auth
|
|
def report_cost():
|
|
group = request.args.get('group', 'project')
|
|
if group == 'worker':
|
|
rows = db.q(
|
|
'SELECT worker_id, provider, model, COUNT(*) runs, SUM(total_tokens) tokens, '
|
|
'SUM(cost) cost FROM cost_records GROUP BY worker_id ORDER BY cost DESC')
|
|
for r in rows:
|
|
w = db.q('SELECT name FROM workers WHERE id=?', (r['worker_id'],), one=True)
|
|
r['worker_name'] = w['name'] if w else f'#{r["worker_id"]}'
|
|
elif group == 'model':
|
|
rows = db.q(
|
|
'SELECT provider, model, COUNT(*) runs, SUM(total_tokens) tokens, '
|
|
'SUM(cost) cost FROM cost_records GROUP BY model ORDER BY cost DESC')
|
|
else:
|
|
rows = db.q(
|
|
'SELECT project_id, COUNT(*) runs, SUM(total_tokens) tokens, '
|
|
'SUM(cost) cost FROM cost_records GROUP BY project_id ORDER BY cost DESC')
|
|
for r in rows:
|
|
p = db.q('SELECT name FROM projects WHERE id=?', (r['project_id'],), one=True)
|
|
r['project_name'] = p['name'] if p else f'#{r["project_id"]}'
|
|
return jsonify({'ok': True, 'data': rows})
|
|
|
|
|
|
@app.route('/api/stats')
|
|
@require_auth
|
|
def stats():
|
|
out = {'projects': 0, 'tasks': 0, 'workers': 0, 'total_cost': 0, 'total_tokens': 0,
|
|
'by_status': {}, 'recent': [], 'daily_cost': []}
|
|
out['projects'] = db.q('SELECT COUNT(*) c FROM projects')[0]['c']
|
|
out['workers'] = db.q('SELECT COUNT(*) c FROM workers')[0]['c']
|
|
out['tasks'] = db.q('SELECT COUNT(*) c FROM tasks')[0]['c']
|
|
for r in db.q('SELECT status, COUNT(*) c FROM tasks GROUP BY status'):
|
|
out['by_status'][r['status']] = r['c']
|
|
c = db.q('SELECT COALESCE(SUM(cost),0) cost, COALESCE(SUM(total_tokens),0) tokens '
|
|
'FROM cost_records')[0]
|
|
out['total_cost'], out['total_tokens'] = round(c['cost'], 4), c['tokens']
|
|
|
|
# 一次通过率:done 且 rejection_count=0
|
|
done = out['by_status'].get('done', 0)
|
|
clean = db.q('SELECT COUNT(*) c FROM tasks WHERE status="done" AND rejection_count=0')[0]['c']
|
|
out['one_pass_rate'] = round(clean / done * 100, 1) if done else None
|
|
out['rework_count'] = db.q('SELECT COALESCE(SUM(rejection_count),0) c FROM tasks')[0]['c']
|
|
|
|
out['recent'] = db.q(
|
|
'SELECT t.id, t.title, t.status, t.project_id, p.name AS project_name, t.updated_at '
|
|
'FROM tasks t LEFT JOIN projects p ON p.id=t.project_id '
|
|
'ORDER BY t.updated_at DESC LIMIT 10')
|
|
|
|
# 近 7 天成本
|
|
import datetime
|
|
rows = db.q('SELECT created_at, cost FROM cost_records ORDER BY created_at')
|
|
day_map = {}
|
|
for r in rows:
|
|
d = datetime.datetime.fromtimestamp(r['created_at']).strftime('%m-%d')
|
|
day_map[d] = round(day_map.get(d, 0) + r['cost'], 4)
|
|
for i in range(6, -1, -1):
|
|
d = (datetime.datetime.now() - datetime.timedelta(days=i)).strftime('%m-%d')
|
|
out['daily_cost'].append({'day': d, 'cost': day_map.get(d, 0)})
|
|
return jsonify({'ok': True, 'data': out})
|
|
|
|
|
|
@app.route('/api/logs')
|
|
@require_auth
|
|
def logs():
|
|
limit = min(int(request.args.get('limit', 100)), 500)
|
|
rows = db.q(
|
|
'SELECT l.*, t.title AS task_title FROM task_logs l '
|
|
'LEFT JOIN tasks t ON t.id=l.task_id ORDER BY l.id DESC LIMIT ?', (limit,))
|
|
return jsonify({'ok': True, 'data': rows})
|
|
|
|
|
|
@app.route('/api/projects/<int:pid>/workflow/run', methods=['POST'])
|
|
@require_auth
|
|
def workflow_run(pid):
|
|
"""执行整个工作流:跑所有就绪(无未完成前置)任务"""
|
|
rows = db.q('SELECT * FROM tasks WHERE project_id=? AND status IN ("todo","failed")', (pid,))
|
|
started, blocked = [], []
|
|
for t in rows:
|
|
ok, blockers = engine.check_dependencies(t)
|
|
if ok:
|
|
if engine.runner.submit(t['id']):
|
|
started.append({'id': t['id'], 'title': t['title']})
|
|
else:
|
|
blocked.append({'id': t['id'], 'title': t['title'], 'by': blockers})
|
|
return jsonify({'ok': True, 'started': started, 'blocked': blocked})
|
|
|
|
|
|
@app.route('/api/projects/<int:pid>/dag')
|
|
@require_auth
|
|
def project_dag(pid):
|
|
"""DAG 图数据:节点 + 边"""
|
|
rows = db.q('SELECT * FROM tasks WHERE project_id=? ORDER BY id', (pid,))
|
|
nodes, edges, id_map = [], [], {}
|
|
for t in rows:
|
|
node = db.serialize_task(t)
|
|
nodes.append(node)
|
|
id_map[t['id']] = node
|
|
for t in nodes:
|
|
for dep_id in t['depends_on']:
|
|
if dep_id in id_map:
|
|
edges.append({'from': dep_id, 'to': t['id']})
|
|
return jsonify({'ok': True, 'nodes': nodes, 'edges': edges})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AI 辅助规划(WBS 生成 + 导入)
|
|
# ---------------------------------------------------------------------------
|
|
WBS_PROMPT = (
|
|
'你是资深项目经理。请把下面的项目目标拆解为可执行的任务列表(WBS),'
|
|
'要求:\n1. 输出严格 JSON,格式 {{"tasks": [{{"title": "任务标题", '
|
|
'"description": "给AI Worker的执行指令(含要求与输出格式)", "depends_on": [0,2]}}]}}\n'
|
|
'2. depends_on 是前置任务的数组下标(无依赖填 []),下标从 0 开始\n'
|
|
'3. 4~8 个任务,逻辑清晰,可并行任务并行,不要输出 JSON 以外的任何内容\n\n'
|
|
'项目目标:{goal}\n'
|
|
'项目验收标准:{accept}'
|
|
)
|
|
|
|
|
|
def _parse_wbs(text):
|
|
"""从 LLM 输出中提取 JSON"""
|
|
t = text.strip()
|
|
if t.startswith('```'):
|
|
t = t.strip('`')
|
|
if t.startswith('json'):
|
|
t = t[4:]
|
|
t = t.strip()
|
|
start = min([i for i in (t.find('{'), t.find('[')) if i >= 0] or [0])
|
|
end = max(t.rfind('}'), t.rfind(']')) + 1
|
|
data = _json.loads(t[start:end])
|
|
tasks = data['tasks'] if isinstance(data, dict) else data
|
|
assert isinstance(tasks, list) and tasks, '任务列表为空'
|
|
return tasks
|
|
|
|
|
|
@app.route('/api/projects/<int:pid>/wbs/generate', methods=['POST'])
|
|
@require_auth
|
|
def wbs_generate(pid):
|
|
d = request.get_json(force=True) or {}
|
|
goal = d.get('goal') or ''
|
|
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
|
if not proj:
|
|
return jsonify({'ok': False, 'error': '项目不存在'}), 404
|
|
if not goal:
|
|
goal = proj.get('objective') or proj.get('name') or ''
|
|
if not goal:
|
|
return jsonify({'ok': False, 'error': '请提供项目目标'}), 400
|
|
worker = db.q('SELECT * FROM workers WHERE status="enabled" ORDER BY id', one=True)
|
|
if not worker:
|
|
return jsonify({'ok': False, 'error': '请先注册至少一个 Worker 用于规划'}), 400
|
|
try:
|
|
r = llm_gateway.chat(worker['provider'], worker['model'], [
|
|
{'role': 'system', 'content': '你只输出 JSON,不输出任何解释文字。'},
|
|
{'role': 'user', 'content': WBS_PROMPT.format(goal=goal, accept=proj.get('acceptance_criteria') or '—')},
|
|
], temperature=0.3, max_tokens=3000)
|
|
tasks = _parse_wbs(r['text'])
|
|
return jsonify({'ok': True, 'data': tasks, 'usage': r['total_tokens'], 'cost': r['cost']})
|
|
except Exception as e:
|
|
return jsonify({'ok': False, 'error': f'WBS 生成失败:{e}'}), 500
|
|
|
|
|
|
@app.route('/api/projects/<int:pid>/wbs/import', methods=['POST'])
|
|
@require_auth
|
|
def wbs_import(pid):
|
|
d = request.get_json(force=True)
|
|
tasks = d.get('tasks') or []
|
|
worker_id = d.get('worker_id')
|
|
if not tasks:
|
|
return jsonify({'ok': False, 'error': '任务列表为空'}), 400
|
|
created = []
|
|
for i, t in enumerate(tasks):
|
|
dep_idx = t.get('depends_on') or []
|
|
dep_ids = [created[idx] for idx in dep_idx
|
|
if isinstance(idx, int) and 0 <= idx < len(created)]
|
|
tid = db.w(
|
|
'INSERT INTO tasks (project_id, worker_id, title, description, priority, '
|
|
'review_required, deadline, depends_on, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
|
(pid, worker_id, t.get('title', f'任务{i+1}'), t.get('description', ''),
|
|
t.get('priority', 'medium'), 1, '', _json.dumps(dep_ids), db.now(), db.now()))
|
|
created.append(tid)
|
|
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
|
(tid, 'info', f'AI 规划导入:{t.get("title", "")}', db.now()))
|
|
return jsonify({'ok': True, 'created': len(created), 'ids': created})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RAG 知识库
|
|
# ---------------------------------------------------------------------------
|
|
@app.route('/api/projects/<int:pid>/documents', methods=['GET', 'POST'])
|
|
@require_auth
|
|
def documents(pid):
|
|
if request.method == 'POST':
|
|
d = request.get_json(force=True)
|
|
doc_id = db.w(
|
|
'INSERT INTO documents (project_id, name, content, source, created_at, updated_at) '
|
|
'VALUES (?,?,?,?,?,?)',
|
|
(pid, d.get('name', '未命名文档').strip(), d.get('content', ''),
|
|
d.get('source', 'manual'), db.now(), db.now()))
|
|
n = rag.rebuild_document(doc_id)
|
|
return jsonify({'ok': True, 'id': doc_id, 'chunks': n})
|
|
rows = db.q('SELECT * FROM documents WHERE project_id=? ORDER BY id DESC', (pid,))
|
|
for r in rows:
|
|
r['chunks'] = db.q('SELECT COUNT(*) c FROM doc_chunks WHERE document_id=?', (r['id'],))[0]['c']
|
|
return jsonify({'ok': True, 'data': rows})
|
|
|
|
|
|
@app.route('/api/documents/<int:doc_id>', methods=['GET', 'PUT', 'DELETE'])
|
|
@require_auth
|
|
def document_detail(doc_id):
|
|
doc = db.q('SELECT * FROM documents WHERE id=?', (doc_id,), one=True)
|
|
if not doc:
|
|
return jsonify({'ok': False, 'error': '文档不存在'}), 404
|
|
if request.method == 'GET':
|
|
return jsonify({'ok': True, 'data': doc})
|
|
if request.method == 'DELETE':
|
|
db.w('DELETE FROM doc_chunks WHERE document_id=?', (doc_id,))
|
|
db.w('DELETE FROM documents WHERE id=?', (doc_id,))
|
|
return jsonify({'ok': True})
|
|
d = request.get_json(force=True)
|
|
if 'content' in d:
|
|
db.w('UPDATE documents SET content=?, updated_at=? WHERE id=?', (d['content'], db.now(), doc_id))
|
|
rag.rebuild_document(doc_id)
|
|
if 'name' in d:
|
|
db.w('UPDATE documents SET name=? WHERE id=?', (d['name'].strip(), doc_id))
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/projects/<int:pid>/search')
|
|
@require_auth
|
|
def kb_search(pid):
|
|
q = request.args.get('q', '')
|
|
if not q:
|
|
return jsonify({'ok': True, 'data': []})
|
|
hits, hit = rag.search_project(pid, q, top_k=5)
|
|
return jsonify({'ok': True, 'data': hits if hit else [], 'hit': hit})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 告警中心
|
|
# ---------------------------------------------------------------------------
|
|
@app.route('/api/alerts')
|
|
@require_auth
|
|
def alerts():
|
|
limit = min(int(request.args.get('limit', 100)), 500)
|
|
rows = db.q('SELECT * FROM alerts ORDER BY id DESC LIMIT ?', (limit,))
|
|
return jsonify({'ok': True, 'data': rows})
|
|
|
|
|
|
@app.route('/api/alerts/unread_count')
|
|
@require_auth
|
|
def alerts_unread():
|
|
c = db.q('SELECT COUNT(*) c FROM alerts WHERE read=0')[0]['c']
|
|
return jsonify({'ok': True, 'count': c})
|
|
|
|
|
|
@app.route('/api/alerts/<int:aid>/read', methods=['POST'])
|
|
@require_auth
|
|
def alert_read(aid):
|
|
db.w('UPDATE alerts SET read=1 WHERE id=?', (aid,))
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/alerts/read_all', methods=['POST'])
|
|
@require_auth
|
|
def alerts_read_all():
|
|
db.w('UPDATE alerts SET read=1 WHERE read=0')
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 开放 API Token
|
|
# ---------------------------------------------------------------------------
|
|
@app.route('/api/tokens', methods=['GET', 'POST'])
|
|
@require_auth
|
|
def api_tokens():
|
|
if request.method == 'POST':
|
|
d = request.get_json(force=True)
|
|
tok = secrets.token_hex(24)
|
|
db.w('INSERT INTO api_tokens (name, token, created_at) VALUES (?,?,?)',
|
|
(d.get('name', '未命名').strip(), tok, db.now()))
|
|
return jsonify({'ok': True, 'token': tok})
|
|
rows = db.q('SELECT id, name, created_at, last_used_at FROM api_tokens ORDER BY id DESC')
|
|
return jsonify({'ok': True, 'data': rows})
|
|
|
|
|
|
@app.route('/api/tokens/<int:tid>', methods=['DELETE'])
|
|
@require_auth
|
|
def api_token_delete(tid):
|
|
db.w('DELETE FROM api_tokens WHERE id=?', (tid,))
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 通知渠道(飞书/企微/邮件)
|
|
# ---------------------------------------------------------------------------
|
|
@app.route('/api/channels', methods=['GET', 'POST'])
|
|
@require_auth
|
|
def channels():
|
|
if request.method == 'POST':
|
|
d = request.get_json(force=True)
|
|
cid = db.w(
|
|
'INSERT INTO notify_channels (name, type, webhook, email, events, enabled, created_at) '
|
|
'VALUES (?,?,?,?,?,?,?)',
|
|
(d.get('name', '').strip(), d.get('type', 'feishu'), d.get('webhook', ''),
|
|
d.get('email', ''), _json.dumps(d.get('events') or []),
|
|
1 if d.get('enabled', True) else 0, db.now()))
|
|
return jsonify({'ok': True, 'id': cid})
|
|
rows = db.q('SELECT * FROM notify_channels ORDER BY id DESC')
|
|
for r in rows:
|
|
try:
|
|
r['events'] = _json.loads(r['events'] or '[]')
|
|
except Exception:
|
|
r['events'] = []
|
|
return jsonify({'ok': True, 'data': rows})
|
|
|
|
|
|
@app.route('/api/channels/<int:cid>', methods=['PUT', 'DELETE'])
|
|
@require_auth
|
|
def channel_detail(cid):
|
|
if request.method == 'DELETE':
|
|
db.w('DELETE FROM notify_channels WHERE id=?', (cid,))
|
|
return jsonify({'ok': True})
|
|
d = request.get_json(force=True)
|
|
fields = ['name', 'type', 'webhook', 'email', 'enabled']
|
|
sets, args = [], []
|
|
for f in fields:
|
|
if f in d:
|
|
sets.append(f'{f}=?')
|
|
args.append(d[f])
|
|
if 'events' in d:
|
|
sets.append('events=?')
|
|
args.append(_json.dumps(d['events'] or []))
|
|
if sets:
|
|
db.w(f'UPDATE notify_channels SET {", ".join(sets)} WHERE id=?', (*args, cid))
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
@app.route('/api/channels/<int:cid>/test', methods=['POST'])
|
|
@require_auth
|
|
def channel_test(cid):
|
|
ch = db.q('SELECT * FROM notify_channels WHERE id=?', (cid,), one=True)
|
|
if not ch:
|
|
return jsonify({'ok': False, 'error': '渠道不存在'}), 404
|
|
ok, msg = notify.test_channel(ch)
|
|
return jsonify({'ok': ok, 'msg': msg})
|
|
|
|
|
|
@app.route('/api/events')
|
|
@require_auth
|
|
def events():
|
|
return jsonify({'ok': True, 'data': [{'id': k, 'name': v} for k, v in notify.EVENTS.items()]})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 设置
|
|
# ---------------------------------------------------------------------------
|
|
@app.route('/api/settings', methods=['GET', 'PUT'])
|
|
@require_auth
|
|
def settings():
|
|
if request.method == 'PUT':
|
|
d = request.get_json(force=True)
|
|
for k, v in d.items():
|
|
db.set_setting(k, v)
|
|
if 'budget_alert_ratio' in d:
|
|
config.BUDGET_ALERT_RATIO = float(d['budget_alert_ratio'])
|
|
return jsonify({'ok': True})
|
|
return jsonify({'ok': True, 'data': {
|
|
'budget_alert_ratio': config.BUDGET_ALERT_RATIO,
|
|
'auth_enabled': auth_enabled(),
|
|
'email_configured': bool(config.EMAIL.get('host')),
|
|
}})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 前端
|
|
# ---------------------------------------------------------------------------
|
|
@app.route('/')
|
|
def index():
|
|
return send_from_directory(app.static_folder, 'index.html')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print(f'AI Worker 项目管理平台启动: http://0.0.0.0:{config.PORT}')
|
|
app.run(host=config.HOST, port=config.PORT, debug=False, threaded=True)
|