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:
+164
-2
@@ -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.4:done=已验收完成;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:
|
||||
|
||||
Reference in New Issue
Block a user