V3.4 负责人验收制+邮件修复+事件面板+真实网页交付

- 项目级验收:全部任务完成后进入「待验收」,邮件通知负责人,验收通过才算完成;公开验收链接 /review/<token> 免登录一键通过/打回(打回必填原因)
- 打回自动返工:自动生成含负责人意见的返工任务,AI主管立即重做并再次提交验收;平台内也可验收
- 真实网页交付:任务HTML产出自动落盘工作目录(剥离围栏/前置叙述),Demo展示真实页面;返工产出覆盖入口页;老项目已回填
- 事件记录面板:合并AI主管动态+任务日志+交付记录,可展开/收起(记忆状态),任务事件可点击定位
- 邮件通道修复:补 Date/Message-ID 头(amavisd 拒收 invalid header section 根因),打包附件/通知恢复送达;notify.py 改标准 MIME
- 规划重试:WBS 拆解失败自动重试3次,仍失败邮件通知负责人
- 项目状态新增 review(待验收),全端展示
This commit is contained in:
2026-08-14 13:33:59 +08:00
parent 3b5c06fdd9
commit 7c4cbf8994
10 changed files with 514 additions and 42 deletions
+164 -2
View File
@@ -207,13 +207,19 @@ def auto_complete_if_ready(project_id, base_url=''):
返回 {'ok','msg'} 或 None(未满足条件/异常)。引擎线程与审核接口共用。"""
try:
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
if not proj or proj['status'] == 'done':
# V3.4done=已验收完成;review=已提交待验收(避免 engine 与 autostart 双触发重复通知)
if not proj:
return None
if proj['status'] in ('done', 'review'):
return {'ok': True, 'msg': '项目已处于待验收/完成状态'}
total = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0', (project_id,))[0]['c']
done = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done" AND deleted=0',
(project_id,))[0]['c']
if total == 0 or done < total:
return None
# V3.4:AI 不能替负责人拍板 → 全部任务完成后进入「待负责人验收」
if proj.get('review_required', 1):
return enter_review(proj, base_url)
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), project_id))
ok, msg = notify_project_complete(proj, base_url)
return {'ok': ok, 'msg': msg}
@@ -221,6 +227,157 @@ def auto_complete_if_ready(project_id, base_url=''):
return None
def enter_review(project, base_url=''):
"""V3.4 全部任务完成 → 项目置「待验收」:生成验收令牌 + 邮件通知负责人。
返回 {'ok','msg'}。"""
import secrets
proj = project if isinstance(project, dict) else db.q('SELECT * FROM projects WHERE id=?', (project,), one=True)
if not proj:
return {'ok': False, 'msg': '项目不存在'}
token = (proj.get('review_token') or '').strip() or secrets.token_urlsafe(16)
db.w('UPDATE projects SET status="review", review_token=?, updated_at=? WHERE id=?',
(token, db.now(), proj['id']))
base = (base_url or PUBLIC_BASE_URL).rstrip('/')
link = f'{base}/review/{token}'
s = project_summary(proj)
demo = proj.get('demo_url') or (demo_url_of(proj, base) if proj.get('deliver_type') == 'web' else '')
extra = (
f'🤖 AI 团队已全部完工({s["done"]}/{s["total"]} 个任务,失败 {s["failed"]},成本 ¥{s["cost"]:.4f}\n'
f'现在是您(项目负责人)验收的时候了!请打开下面的链接审核交付成果:\n'
f'👉 验收链接:{link}\n'
+ (f'👀 在线 Demo{demo}\n' if demo else '')
+ '验收通过后项目才算正式完成,并自动打包交付。'
)
email = deliver_email_of(proj)
ok, msg = True, '已通知负责人验收'
if email:
ok, msg = send_mail(email, f'🔔 待您验收:{proj["name"]}AI 团队已完工)',
_email_body(proj, extra=extra))
if not ok:
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
"VALUES ('notify','warn',?,?,0,?)",
(f'验收通知邮件失败:{proj["name"]}', msg[:300], db.now()))
else:
ok, msg = False, '项目未配置送达者邮箱,无法通知验收'
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
"VALUES ('notify','warn',?,?,0,?)",
(f'验收通知失败:{proj["name"]} 未配置送达者', '', db.now()))
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
(proj['id'], 'info', f'项目进入待验收状态,已通知负责人{(""+email+"") if email else ""}{link}', db.now()))
return {'ok': ok, 'msg': msg}
def approve_project(project_id, base_url='', reviewer='负责人'):
"""V3.4 负责人验收通过 → 部署 Demo/打包 + 完成交付邮件 + 项目置 done"""
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
if not proj:
return {'ok': False, 'msg': '项目不存在'}
if proj['status'] != 'review':
return {'ok': False, 'msg': f'项目当前状态「{proj["status"]}」不可验收'}
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), project_id))
ok, msg = notify_project_complete(proj, base_url)
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
(project_id, 'success', f'负责人({reviewer})验收通过 ✅,项目正式完成并交付', db.now()))
return {'ok': ok, 'msg': msg}
def reject_project(project_id, reason=''):
"""V3.4 负责人打回 → 项目回到进行中,记录原因,自动生成返工任务交给 AI 主管重做"""
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
if not proj:
return {'ok': False, 'msg': '项目不存在'}
db.w('UPDATE projects SET status="active", review_token=\'\', updated_at=? WHERE id=?',
(db.now(), project_id))
msg = f'负责人打回:{reason or "未填写原因"}'
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
(project_id, 'warn', msg, db.now()))
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
"VALUES ('reject','warn',?,?,0,?)",
(f'项目被打回:{proj["name"]}', msg[:500], db.now()))
# 自动生成返工任务(AI 主管按负责人意见重做),上层负责触发 autostart.launch
team = db.q('SELECT w.* FROM project_team_workers t JOIN workers w ON w.id=t.worker_id '
'WHERE t.project_id=? ORDER BY t.rowid', (project_id,))
wid = team[0]['id'] if team else None
desc = (f'负责人验收后提出修改意见,请按以下意见返工整个交付物:\n'
f'【负责人意见】{reason or "未填写"}\n\n'
f'项目目标:{proj.get("objective") or proj.get("name") or ""}\n'
f'验收标准:{proj.get("acceptance_criteria") or ""}\n'
f'请直接输出修改后的完整成果(网页项目请输出完整 HTML 文档)。')
tid = db.w('INSERT INTO tasks (project_id, worker_id, title, description, priority, '
'review_required, deadline, depends_on, created_at, updated_at) '
'VALUES (?,?,?,?,?,?,?,?,?,?)',
(project_id, wid, '返工:按负责人意见修改', desc, 'high', 0, '', '[]',
db.now(), db.now()))
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
(tid, 'warn', f'负责人打回,AI 主管返工任务已创建:{reason[:120]}', db.now()))
return {'ok': True, 'msg': msg, 'rework_task_id': tid}
def _strip_fence(text):
"""去掉 LLM 输出外层 markdown 代码围栏(```html ... ```),还原纯 HTML"""
t = text.strip()
if not t.startswith('```'):
return text
lines = t.split('\n')
if lines and lines[0].startswith('```'):
lines = lines[1:]
if lines and lines[-1].strip() == '```':
lines = lines[:-1]
return '\n'.join(lines).strip()
def _extract_html(text):
"""从 LLM 输出中提取完整 HTML 文档:全文本定位 <!doctype html/<html 标签,
截取到 </html>;自动剥离 markdown 代码围栏与前置叙述。返回纯 HTML 或 None。"""
if not text or not isinstance(text, str):
return None
import re as _re
m = _re.search(r'<!doctype\s+html', text, _re.I)
if not m:
m = _re.search(r'<html[\s>]', text, _re.I)
if not m:
return None
html = text[m.start():]
end = html.lower().rfind('</html>')
if end > 0:
html = html[:end + 7]
# 剥离可能残留的围栏(如 ```html 前缀 / 结尾 ```
html = _strip_fence(html)
return html.strip()
def save_task_output(task, text):
"""V3.4 任务产出若为完整网页文档 → 落盘到项目工作目录,供 Demo 真实展示。
返回保存的文件名列表。"""
content = _extract_html(text)
if content is None:
return []
saved = []
root = workspace_path(task['project_id'])
title = (task.get('title') or '').strip()
is_rework = '返工' in title
index = os.path.join(root, 'index.html')
index_bad = False
if os.path.isfile(index):
try:
with open(index, 'r', encoding='utf-8') as fh:
index_bad = fh.read().lstrip().startswith('```')
except Exception:
index_bad = True
if not os.path.isfile(index) or index_bad or is_rework:
fn = 'index.html'
else:
safe = re.sub(r'[\\/:*?"<>|\s]+', '_', title or f'task{task["id"]}')[:40]
fn = f'{safe}-{task["id"]}.html'
try:
with open(os.path.join(root, fn), 'w', encoding='utf-8') as fh:
fh.write(content)
saved.append(fn)
except Exception:
pass
return saved
def project_summary(project):
"""项目交付摘要:任务统计 + 成本"""
rows = db.q('SELECT status, COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0 GROUP BY status',
@@ -238,15 +395,20 @@ def project_summary(project):
# 邮件发送(支持附件)
# ---------------------------------------------------------------------------
def send_mail(to_addr, subject, text, attachments=None, html=None):
"""发送邮件到任意收件人(送达者),支持附件。返回 (ok, msg)"""
"""发送邮件到任意收件人(送达者),支持附件。返回 (ok, msg)
注意:邮件头必须带 Date/Message-ID,否则 mail.tphai.com 的 amavisd 内容过滤器
会以 "invalid header section / Missing required header field: Date" 退信。"""
if not EMAIL.get('host'):
return False, '邮件服务未配置(config.EMAIL.host 为空)'
if not to_addr:
return False, '收件邮箱为空'
from email.utils import formatdate, make_msgid
msg = MIMEMultipart()
msg['From'] = formataddr((EMAIL.get('from_name', 'AI Worker 平台'), EMAIL['user']))
msg['To'] = to_addr
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid(domain='tphai.com')
if html:
msg.attach(MIMEText(html, 'html', 'utf-8'))
else: