V1: DAG多任务编排 + AI规划WBS + RAG知识库 + 预算告警中心 + 开放API + 飞书/企微/邮件通知

This commit is contained in:
2026-08-12 00:48:52 +08:00
parent e046770bad
commit 29874e6ca9
13 changed files with 1402 additions and 111 deletions
+337 -5
View File
@@ -4,13 +4,17 @@ 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
@@ -46,11 +50,27 @@ 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 auth_enabled() and not session.get('authed'):
return jsonify({'ok': False, 'error': '未登录'}), 401
if not _auth_ok():
return jsonify({'ok': False, 'error': '未登录或 Token 无效'}), 401
return fn(*args, **kwargs)
return wrapper
@@ -199,11 +219,11 @@ def tasks():
d = request.get_json(force=True)
tid = db.w(
'INSERT INTO tasks (project_id, worker_id, title, description, priority, '
'review_required, deadline, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)',
'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', ''),
db.now(), db.now()))
_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})
@@ -245,6 +265,9 @@ def task_detail(tid):
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))
@@ -259,6 +282,9 @@ def task_run(tid):
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
@@ -295,6 +321,11 @@ def task_review(tid):
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:
@@ -330,7 +361,7 @@ def report_cost():
'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)
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})
@@ -383,6 +414,307 @@ def logs():
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')),
}})
# ---------------------------------------------------------------------------
# 前端
# ---------------------------------------------------------------------------