Files
hz4th_coder 7c4cbf8994 V3.4 负责人验收制+邮件修复+事件面板+真实网页交付
- 项目级验收:全部任务完成后进入「待验收」,邮件通知负责人,验收通过才算完成;公开验收链接 /review/<token> 免登录一键通过/打回(打回必填原因)
- 打回自动返工:自动生成含负责人意见的返工任务,AI主管立即重做并再次提交验收;平台内也可验收
- 真实网页交付:任务HTML产出自动落盘工作目录(剥离围栏/前置叙述),Demo展示真实页面;返工产出覆盖入口页;老项目已回填
- 事件记录面板:合并AI主管动态+任务日志+交付记录,可展开/收起(记忆状态),任务事件可点击定位
- 邮件通道修复:补 Date/Message-ID 头(amavisd 拒收 invalid header section 根因),打包附件/通知恢复送达;notify.py 改标准 MIME
- 规划重试:WBS 拆解失败自动重试3次,仍失败邮件通知负责人
- 项目状态新增 review(待验收),全端展示
2026-08-14 13:33:59 +08:00

324 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
任务执行引擎 V1
- DAG 依赖校验 + 下游自动触发(串行/并行)
- RAG 知识库上下文注入
- 预算告警(项目预算使用率阈值)
- 事件通知(待审核/完成/失败)与告警记录
"""
import json
import threading
import traceback
import db
import llm_gateway
import config
import rag
import notify
import delivery
def _notify_failed(task, message):
"""任务失败:写告警 + 推送渠道 + 邮件通知送达者(项目难关)"""
notify.notify('task_failed', f'任务失败:{task["title"]}',
f'项目 #{task["project_id"]} 任务「{task["title"]}{message}',
save_alert=True, level='warn', atype='task_failed')
try:
proj = db.q('SELECT * FROM projects WHERE id=?', (task['project_id'],), one=True)
if proj and (proj.get('deliver_email') or '').strip():
delivery.notify_blocker(proj, task['title'], message)
except Exception:
pass
def _log(task_id, level, message):
try:
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
(task_id, level, message, db.now()))
except Exception:
pass
def _set_task(task_id, **fields):
if not fields:
return
fields['updated_at'] = db.now()
sets = ', '.join(f'{k}=?' for k in fields)
db.w(f'UPDATE tasks SET {sets} WHERE id=?', (*fields.values(), task_id))
def _cost_record(task, worker, usage):
db.w(
'INSERT INTO cost_records (task_id, project_id, worker_id, provider, model, '
'prompt_tokens, completion_tokens, total_tokens, cost, created_at) '
'VALUES (?,?,?,?,?,?,?,?,?,?)',
(task['id'], task['project_id'], worker['id'], worker['provider'],
worker['model'], usage['prompt_tokens'], usage['completion_tokens'],
usage['total_tokens'], usage['cost'], db.now()))
def _deps(task):
try:
return json.loads(task.get('depends_on') or '[]')
except Exception:
return []
def check_dependencies(task):
"""DAG 依赖检查:返回 (ok, blockers)"""
blockers = []
for dep_id in _deps(task):
dep = db.q('SELECT id, title, status FROM tasks WHERE id=?', (dep_id,), one=True)
if not dep:
blockers.append(f'# {dep_id}(已删除)')
elif dep['status'] != 'done':
blockers.append(f'「{dep["title"]}」(#{dep_id}) {dep["status"]}')
return (not blockers), blockers
def pick_worker_auto(task):
"""自动路由:按模型输入单价升序挑选 enabled Worker"""
rows = db.q('SELECT * FROM workers WHERE status="enabled" ORDER BY id')
if not rows:
return None
best, best_price = None, None
for r in rows:
pin, pout = llm_gateway.model_price(r['model'])
price = pin + pout * 0.5
if best_price is None or price < best_price:
best, best_price = r, price
return best
def check_worker_limits(worker):
"""成本上限预检:返回 (ok, reason)"""
if worker['task_cost_limit'] and worker['task_cost_limit'] > 0:
used = db.task_worker_cost(worker['id'])
if used >= worker['task_cost_limit']:
return False, f'该 Worker 累计成本 {used:.4f} 元已达单任务上限 {worker["task_cost_limit"]} 元'
if worker['monthly_cost_limit'] and worker['monthly_cost_limit'] > 0:
used = db.monthly_worker_cost(worker['id'])
if used >= worker['monthly_cost_limit']:
return False, f'该 Worker 本月成本 {used:.4f} 元已达月度上限 {worker["monthly_cost_limit"]} 元'
return True, ''
def _project_cost(project_id):
rows = db.q('SELECT COALESCE(SUM(cost),0) AS t FROM cost_records WHERE project_id=?',
(project_id,))
return rows[0]['t'] if rows else 0.0
def _check_project_budget(task):
proj = db.q('SELECT * FROM projects WHERE id=?', (task['project_id'],), one=True)
if proj and proj['budget_limit'] and proj['budget_limit'] > 0:
used = _project_cost(task['project_id'])
if used >= proj['budget_limit']:
return False, f'项目预算已用完({used:.2f}/{proj["budget_limit"]:.2f} 元)'
return True, ''
def _budget_alert(project_id):
"""预算使用率告警(每次任务完成后检查,避免重复刷屏)"""
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
if not proj or not proj['budget_limit'] or proj['budget_limit'] <= 0:
return
used = _project_cost(project_id)
ratio = used / proj['budget_limit']
if ratio >= config.BUDGET_ALERT_RATIO:
# 同项目 1 小时内只告警一次,避免刷屏
dup = db.q('SELECT COUNT(*) c FROM alerts WHERE type="budget" AND detail LIKE ? '
'AND created_at > ?', (f'项目「{proj["name"]}」%', db.now() - 3600))
if dup[0]['c'] == 0:
notify.notify('budget_alert',
f'预算告警:项目「{proj["name"]}」已使用 {ratio*100:.0f}%',
f'已花费 ¥{used:.2f} / 预算 ¥{proj["budget_limit"]:.2f}'
f'超过阈值 {config.BUDGET_ALERT_RATIO*100:.0f}%,请关注成本控制。',
save_alert=True, level='warn', atype='budget')
def _build_messages(task, worker):
"""构造提示词:任务指令 + RAG 知识库上下文
支持图片注入:任务描述中的 ![说明](图片URL) 或 图片:URL 会转成多模态消息(视觉 Worker)。"""
import re as _re
messages = []
if worker['system_prompt']:
messages.append({'role': 'system', 'content': worker['system_prompt']})
user_text = task['description'] or task['title']
ctx, refs = rag.build_context(task['project_id'], user_text)
if ctx:
user_text = f'{ctx}\n\n----\n\n任务指令:{user_text}'
_log(task['id'], 'info', f'📚 RAG 知识库命中 {len(refs)} 个片段:' + ''.join(refs[:5]))
# 提取图片 URL![alt](url) 或 图片:url 或 image:url
img_urls = []
for m in _re.finditer(r'!\[[^\]]*\]\(([^)\s]+)\)', user_text):
img_urls.append(m.group(1))
for m in _re.finditer(r'(?:图片|image)\s*[:]\s*(https?://\S+)', user_text, _re.I):
img_urls.append(m.group(1))
if img_urls:
content = [{'type': 'text', 'text': user_text}]
for u in img_urls:
content.append({'type': 'image_url', 'image_url': {'url': u}})
messages.append({'role': 'user', 'content': content})
_log(task['id'], 'info', f'🖼️ 检测到 {len(img_urls)} 张图片,已注入多模态消息')
else:
messages.append({'role': 'user', 'content': user_text})
return messages
def _trigger_downstream(task):
"""DAG:任务完成后自动触发所有就绪的下游任务"""
rows = db.q('SELECT * FROM tasks WHERE deleted=0 AND status IN ("todo","failed")')
triggered = []
for t in rows:
deps = _deps(t)
if task['id'] not in deps:
continue
ok, blockers = check_dependencies(t)
if ok:
if runner.submit(t['id']):
triggered.append(t)
else:
_log(t['id'], 'info', f'⏳ 等待前置任务完成:' + '、'.join(blockers))
return triggered
def run_task(task_id):
"""在后台线程中执行任务"""
task = db.q('SELECT * FROM tasks WHERE id=?', (task_id,), one=True)
if not task:
return
if task['status'] == 'running':
return
# DAG 依赖检查
ok, blockers = check_dependencies(task)
if not ok:
_set_task(task_id, status='failed', error='前置任务未完成:' + '、'.join(blockers),
finished_at=db.now())
_log(task_id, 'error', '❌ 依赖未满足,无法执行:' + '、'.join(blockers))
_notify_failed(task, '因依赖未完成被拒绝执行:' + '、'.join(blockers))
return
# 确定 Worker
worker = None
if task['worker_id']:
worker = db.q('SELECT * FROM workers WHERE id=?', (task['worker_id'],), one=True)
if not worker or worker['status'] != 'enabled':
_set_task(task_id, status='failed', error='指定 Worker 不存在或已停用',
finished_at=db.now())
_log(task_id, 'error', '指定 Worker 不存在或已停用')
_notify_failed(task, '指定 Worker 不存在或已停用,无法执行')
return
else:
worker = pick_worker_auto(task)
if not worker:
_set_task(task_id, status='failed', error='无可用 Worker(自动路由失败)',
finished_at=db.now())
_log(task_id, 'error', '自动路由失败:无可用 Worker')
_notify_failed(task, '自动路由失败:没有可用的 Worker')
return
_set_task(task_id, worker_id=worker['id'])
_log(task_id, 'info', f'自动路由 → Worker「{worker["name"]}」({worker["provider"]}/{worker["model"]}')
# 预算/成本预检
ok, reason = check_worker_limits(worker)
if not ok:
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
_log(task_id, 'error', reason)
_notify_failed(task, reason)
return
ok, reason = _check_project_budget(task)
if not ok:
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
_log(task_id, 'error', reason)
_notify_failed(task, reason)
return
_set_task(task_id, status='running', started_at=db.now(), error='')
_log(task_id, 'info', f'开始执行:Worker「{worker["name"]}」 模型 {worker["provider"]}/{worker["model"]}')
try:
usage = llm_gateway.chat(
worker['provider'], worker['model'], _build_messages(task, worker),
temperature=worker['temperature'], max_tokens=worker['max_tokens'],
base_url=worker['base_url'] or None, api_key=worker['api_key'] or None)
except Exception as e:
_set_task(task_id, status='failed', error=str(e), finished_at=db.now())
_log(task_id, 'error', f'执行失败: {e}')
_notify_failed(task, f'执行出错:{str(e)[:300]}')
return
_cost_record(task, worker, usage)
_log(task_id, 'success',
f'执行完成:{usage["total_tokens"]} tokens(输入 {usage["prompt_tokens"]} / 输出 {usage["completion_tokens"]}),'
f'成本 ¥{usage["cost"]:.6f}')
# V3.4:产出若为完整网页文档 → 落盘工作目录,供 Demo 真实展示
try:
saved = delivery.save_task_output(task, usage['text'])
if saved:
_log(task_id, 'info', f'🌐 网页产出已保存到工作目录:' + '、'.join(saved))
except Exception:
pass
new_status = 'review' if task['review_required'] else 'done'
fields = {
'status': new_status,
'output_text': usage['text'],
'output_version': task['output_version'] + 1,
'finished_at': db.now(),
}
_set_task(task_id, **fields)
if new_status == 'review':
_log(task_id, 'info', '产出已提交,等待人工审核(HITL)')
notify.notify('task_review', f'任务待审核:{task["title"]}',
f'项目 #{task["project_id"]} 任务「{task["title"]}」已完成,等待人工验收。\n'
f'模型 {worker["provider"]}/{worker["model"]} · {usage["total_tokens"]} tokens · ¥{usage["cost"]:.4f}',
save_alert=True, level='info', atype='task_review')
else:
_log(task_id, 'success', '任务完成(无需审核)')
notify.notify('task_done', f'任务完成:{task["title"]}',
f'项目 #{task["project_id"]} 任务「{task["title"]}」执行完毕,'
f'成本 ¥{usage["cost"]:.4f}tokens {usage["total_tokens"]}',
save_alert=False)
_budget_alert(task['project_id'])
if new_status == 'done':
# V3:无需审核的任务直接完成后,检查项目是否全部完成 → 自动交付并通知送达者
try:
delivery.auto_complete_if_ready(task['project_id'])
except Exception:
pass
# DAG:触发下游就绪任务
downstream = _trigger_downstream(task)
for t in downstream:
_log(t['id'], 'info', f'🔗 前置任务「{task["title"]}」已完成,自动触发执行')
class TaskRunner:
def __init__(self):
self._threads = {}
def submit(self, task_id):
if task_id in self._threads and self._threads[task_id].is_alive():
return False
t = threading.Thread(target=self._safe_run, args=(task_id,), daemon=True)
self._threads[task_id] = t
t.start()
return True
def _safe_run(self, task_id):
try:
run_task(task_id)
except Exception:
_log(task_id, 'error', '引擎异常: ' + traceback.format_exc())
try:
_set_task(task_id, status='failed', error='引擎异常,见日志', finished_at=db.now())
except Exception:
pass
runner = TaskRunner()