Commit b339a5dd6ae4f24e4e0d035fbd1adc293a264055

Authored by yibo
1 parent 252f05f4

feat(wechat-auto-reply): 环节4 策略引擎 + wechat-viewer 审批/白名单 UI

wechat_reply_policy.py:确定性安全闸 decide()→skip|auto|review,default-deny
+敏感词硬override(钱/承诺/约时间白名单也拉回),demo 8/8 过。配置与审批 UI 落在
手机端 ~/wechat-viewer(Flask):新表 reply_whitelist/reply_queue、/whitelist
白名单页、/approvals 审批页(可编辑草稿+发送/取消),发送复用 send_text_via_hook。
真机验证白名单增删查、发送到 filehelper(sent)、取消(cancelled) 全通。viewer 改
动全文见 wechat-viewer-changes.md + viewer_templates/(不复制用户整个 app)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
md/wechat-auto-reply-requirements.md
@@ -77,7 +77,17 @@ @@ -77,7 +77,17 @@
77 - ⚠️ 观察:SOUL.md 那句「约时间交本人确认」是**软约束**,约饭案例里模型仍主动接话——真正的自动/审核安全门必须落在**环节4 策略层**,不能只靠人设。 77 - ⚠️ 观察:SOUL.md 那句「约时间交本人确认」是**软约束**,约饭案例里模型仍主动接话——真正的自动/审核安全门必须落在**环节4 策略层**,不能只靠人设。
78 - 待办:一次结构化调用同时出 `{should_reply, confidence, draft, risk_flags}` 留给环节4 一起做。 78 - 待办:一次结构化调用同时出 `{should_reply, confidence, draft, risk_flags}` 留给环节4 一起做。
79 79
80 -### 环节 4 · 自动 vs 审核 —— **白名单直发,其余进 viewer 待回复** 80 +### 环节 4 · 自动 vs 审核 —— ✅ **确定性策略引擎 + viewer 审批/白名单 UI(2026-07-10)**
  81 +- **策略引擎**(代码,非 LLM 判)`wechat_plain/scripts/auto-reply/wechat_reply_policy.py`:`decide(event,draft,confidence)` → `skip|auto|review`。default-deny + 硬安全 override:群未@我/公众号/系统消息/自己发的/模型说别回 → skip;命中敏感词(钱/承诺/约时间/隐私,白名单也拉回)或低置信 → review;仅白名单+总开关开 → auto;其余 → review。demo 8/8 通过。
  82 +- **配置 + 审批 UI 落在手机端 `~/wechat-viewer`(Flask)**,非独立文件(详见 `wechat-viewer-changes.md`):
  83 + - 新表 `reply_whitelist` + `reply_queue`(`local_db.py`)。
  84 + - `/whitelist` 白名单管理页;`/approvals` 审批页(待审列表 + **可编辑草稿** + 【发送】【取消】)。
  85 + - 发送复用 `send_text_via_hook`(LSPosed 内部 API)。
  86 + - 真机验证:白名单增删查、审批发送到 filehelper(status=sent)、取消(cancelled) 全通。
  87 +- ⚠️ 印证环节3 的发现:安全门是**确定性代码**(敏感词硬拦),不靠人设软约束。
  88 +- 待接:编排器把环节1 队列串到策略+队列(见环节5)。
  89 +
  90 +### (原)环节 4 · 自动 vs 审核 —— **白名单直发,其余进 viewer 待回复**
81 - **白名单**(联系人 / 群 / 群里某发言人)+ 生成成功 → **直接发送**。 91 - **白名单**(联系人 / 群 / 群里某发言人)+ 生成成功 → **直接发送**。
82 - **非白名单** → 写入 **Pixel 本地 wechat viewer 的"待回复列表"**,人工点击"发送"才真正发出。 92 - **非白名单** → 写入 **Pixel 本地 wechat viewer 的"待回复列表"**,人工点击"发送"才真正发出。
83 - 落地新增:①「待回复队列」存储(sqlite/json);② wechat viewer 的"发送"按钮接到发送链路。 93 - 落地新增:①「待回复队列」存储(sqlite/json);② wechat viewer 的"发送"按钮接到发送链路。
wechat_plain/scripts/auto-reply/viewer_templates/approvals.html 0 → 100644
  1 +{% extends "base.html" %}
  2 +{% block title %}自动回复审批 - WeChat Viewer{% endblock %}
  3 +{% block body %}
  4 +<style>
  5 +body{overflow-y:auto!important;height:auto!important;}
  6 +.ap-container{max-width:960px;margin:0 auto;padding:20px;}
  7 +.ap-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
  8 +.ap-header h1{font-size:20px;color:#2c3e50;}
  9 +.btn-refresh{background:#95a5a6;color:white;padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:13px;}
  10 +.tabs{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;}
  11 +.tab{padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px;border:1px solid #ddd;background:white;}
  12 +.tab.active{background:#2c3e50;color:white;border-color:#2c3e50;}
  13 +.ap-card{background:white;border-radius:8px;padding:14px 18px;margin-bottom:10px;box-shadow:0 1px 2px rgba(0,0,0,0.05);}
  14 +.ap-card.pending{border-left:3px solid #e67e22;}
  15 +.ap-card.sent{border-left:3px solid #27ae60;opacity:.75;}
  16 +.ap-card.auto_sent{border-left:3px solid #3498db;opacity:.8;}
  17 +.ap-card.cancelled,.ap-card.failed{border-left:3px solid #95a5a6;opacity:.65;}
  18 +.ap-top{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;}
  19 +.ap-name{font-weight:600;font-size:14px;}
  20 +.kind-badge{font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;background:#eef;color:#3a51c6;}
  21 +.kind-badge.group_at_me{background:#fdebd0;color:#b9770e}.kind-badge.group_quote_me{background:#e8daef;color:#6c3483}
  22 +.status-badge{font-size:10px;padding:2px 8px;border-radius:10px;margin-left:auto;}
  23 +.status-badge.sent{background:#d5f5e3;color:#1e8449}.status-badge.auto_sent{background:#d6eaf8;color:#1a5276}
  24 +.status-badge.cancelled,.status-badge.failed{background:#f2f3f4;color:#7f8c8d}
  25 +.reason{font-size:10px;color:#e67e22;margin-bottom:6px;}
  26 +.incoming{background:#f7f7f8;border-radius:6px;padding:8px 10px;font-size:12px;color:#444;margin-bottom:8px;white-space:pre-wrap;word-break:break-word;}
  27 +.incoming .lbl{color:#999;font-size:10px;display:block;margin-bottom:2px;}
  28 +.draft-box{width:100%;box-sizing:border-box;border:1px solid #cfd8e3;border-radius:6px;padding:8px 10px;font-size:13px;resize:vertical;min-height:44px;font-family:inherit;}
  29 +.ap-actions{display:flex;gap:8px;margin-top:8px;}
  30 +.ap-actions button{cursor:pointer;padding:7px 16px;border:none;border-radius:6px;font-size:13px;}
  31 +.btn-send{background:#27ae60;color:white}.btn-send:hover{background:#219a52}
  32 +.btn-cancel{background:#e74c3c;color:white}.btn-cancel:hover{background:#c0392b}
  33 +.sent-reply{font-size:13px;color:#1e8449;margin-top:4px;}
  34 +.ts{font-size:10px;color:#aaa;}
  35 +.empty-state{text-align:center;padding:60px 20px;color:#999;}
  36 +.loading{text-align:center;padding:40px;color:#999;}
  37 +</style>
  38 +<div class="ap-container">
  39 +<div class="ap-header">
  40 +<h1>✅ 自动回复审批</h1>
  41 +<button class="btn-refresh" onclick="load()">🔄 刷新</button>
  42 +</div>
  43 +<div class="tabs">
  44 +<div class="tab active" data-s="pending" onclick="switchTab('pending',this)">待审批</div>
  45 +<div class="tab" data-s="" onclick="switchTab('',this)">全部</div>
  46 +<div class="tab" data-s="sent" onclick="switchTab('sent',this)">已发送</div>
  47 +<div class="tab" data-s="auto_sent" onclick="switchTab('auto_sent',this)">自动已发</div>
  48 +<div class="tab" data-s="cancelled" onclick="switchTab('cancelled',this)">已取消</div>
  49 +</div>
  50 +<div id="list"></div>
  51 +</div>
  52 +<script>
  53 +var curStatus='pending';
  54 +function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
  55 +var STATUS_CN={pending:'待审批',sent:'已发送',auto_sent:'自动已发',cancelled:'已取消',failed:'发送失败'};
  56 +var KIND_CN={private:'私聊',group_at_me:'群@我',group_quote_me:'群引用我'};
  57 +
  58 +function switchTab(s,el){curStatus=s;document.querySelectorAll('.tabs .tab').forEach(function(t){t.classList.remove('active')});el.classList.add('active');load();}
  59 +
  60 +function load(){
  61 + var el=document.getElementById('list');
  62 + el.innerHTML='<div class="loading">加载中...</div>';
  63 + fetch('/api/replies?status='+encodeURIComponent(curStatus)).then(function(r){return r.json()}).then(function(j){
  64 + if(!j.ok){el.innerHTML='<div class="empty-state">加载失败: '+esc(j.error)+'</div>';return;}
  65 + if(!j.data.length){el.innerHTML='<div class="empty-state"><p>暂无</p></div>';return;}
  66 + var h='';
  67 + for(var i=0;i<j.data.length;i++){
  68 + var t=j.data[i];
  69 + var pending=t.status==='pending';
  70 + h+='<div class="ap-card '+t.status+'">';
  71 + h+='<div class="ap-top"><span class="ap-name">'+esc(t.sender_name||t.sender||t.talker)+'</span>';
  72 + h+='<span class="kind-badge '+t.kind+'">'+(KIND_CN[t.kind]||t.kind)+'</span>';
  73 + if(!pending)h+='<span class="status-badge '+t.status+'">'+(STATUS_CN[t.status]||t.status)+'</span>';
  74 + h+='</div>';
  75 + if(pending&&t.reason)h+='<div class="reason">判定:'+esc(t.reason)+'</div>';
  76 + h+='<div class="incoming"><span class="lbl">对方说:</span>'+esc(t.incoming)+'</div>';
  77 + if(pending){
  78 + h+='<textarea class="draft-box" id="d'+t.id+'">'+esc(t.draft)+'</textarea>';
  79 + h+='<div class="ap-actions">';
  80 + h+='<button class="btn-send" onclick="send('+t.id+')">✅ 发送</button>';
  81 + h+='<button class="btn-cancel" onclick="cancel('+t.id+')">🗑 取消</button>';
  82 + h+='</div>';
  83 + }else{
  84 + h+='<div class="sent-reply">回复:'+esc(t.draft)+'</div>';
  85 + h+='<div class="ts">'+esc(t.sent_at||t.created_at)+'</div>';
  86 + }
  87 + h+='</div>';
  88 + }
  89 + el.innerHTML=h;
  90 + }).catch(function(e){el.innerHTML='<div class="empty-state">加载失败: '+e.message+'</div>';});
  91 +}
  92 +
  93 +function send(id){
  94 + var txt=document.getElementById('d'+id).value;
  95 + if(!txt.trim()){alert('回复内容为空');return;}
  96 + fetch('/api/reply/'+id+'/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:txt})})
  97 + .then(function(r){return r.json()}).then(function(j){
  98 + if(j.ok){load();}else{alert('发送失败: '+(j.error||JSON.stringify(j.data||{})));load();}
  99 + });
  100 +}
  101 +function cancel(id){
  102 + fetch('/api/reply/'+id+'/cancel',{method:'POST'}).then(function(r){return r.json()}).then(function(j){
  103 + if(j.ok){load();}else{alert('失败: '+(j.error||''));}
  104 + });
  105 +}
  106 +load();
  107 +</script>
  108 +{% endblock %}
wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html 0 → 100644
  1 +{% extends "base.html" %}
  2 +{% block title %}回复白名单 - WeChat Viewer{% endblock %}
  3 +{% block body %}
  4 +<style>
  5 +body{overflow-y:auto!important;height:auto!important;}
  6 +.wl-container{max-width:760px;margin:0 auto;padding:20px;}
  7 +.wl-header h1{font-size:20px;color:#2c3e50;margin-bottom:6px;}
  8 +.wl-hint{font-size:12px;color:#888;margin-bottom:16px;line-height:1.5;}
  9 +.add-box{background:white;border-radius:8px;padding:14px 16px;margin-bottom:18px;box-shadow:0 1px 2px rgba(0,0,0,0.05);display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
  10 +.add-box input{border:1px solid #cfd8e3;border-radius:6px;padding:8px 10px;font-size:13px;}
  11 +.add-box input.wxid{flex:1;min-width:180px;}
  12 +.add-box input.name{width:140px;}
  13 +.btn-add{background:#27ae60;color:white;border:none;border-radius:6px;padding:8px 16px;font-size:13px;cursor:pointer;}
  14 +.wl-card{background:white;border-radius:8px;padding:12px 16px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,0.05);display:flex;align-items:center;gap:10px;}
  15 +.kb{font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;}
  16 +.kb.contact{background:#d6eaf8;color:#1a5276}.kb.group{background:#fdebd0;color:#b9770e}
  17 +.wl-name{font-weight:600;font-size:14px;}
  18 +.wl-wxid{font-size:11px;color:#999;}
  19 +.btn-del{margin-left:auto;background:#e74c3c;color:white;border:none;border-radius:6px;padding:6px 12px;font-size:12px;cursor:pointer;}
  20 +.empty-state{text-align:center;padding:50px 20px;color:#999;}
  21 +</style>
  22 +<div class="wl-container">
  23 +<div class="wl-header"><h1>⭐ 回复白名单</h1></div>
  24 +<div class="wl-hint">在白名单里的联系人 / 群,其消息可被<b>自动回复</b>(仍受敏感词等安全规则拦截,命中会转人工审批)。不在名单里的一律进「自动回复审批」等你手动发。<br>群 id 以 <code>@chatroom</code> 结尾会自动识别为群。</div>
  25 +<div class="add-box">
  26 +<input class="wxid" id="wxid" placeholder="wxid 或 群id(xxx@chatroom)">
  27 +<input class="name" id="name" placeholder="备注名(可选)">
  28 +<button class="btn-add" onclick="add()">➕ 添加</button>
  29 +</div>
  30 +<div id="list"></div>
  31 +</div>
  32 +<script>
  33 +function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
  34 +function load(){
  35 + var el=document.getElementById('list');
  36 + fetch('/api/whitelist').then(function(r){return r.json()}).then(function(j){
  37 + if(!j.ok){el.innerHTML='<div class="empty-state">加载失败</div>';return;}
  38 + if(!j.data.length){el.innerHTML='<div class="empty-state"><p>白名单为空 — 当前所有回复都会进人工审批</p></div>';return;}
  39 + var h='';
  40 + for(var i=0;i<j.data.length;i++){
  41 + var t=j.data[i];
  42 + h+='<div class="wl-card">';
  43 + h+='<span class="kb '+t.kind+'">'+(t.kind==='group'?'群':'联系人')+'</span>';
  44 + h+='<span class="wl-name">'+esc(t.name||'(未命名)')+'</span>';
  45 + h+='<span class="wl-wxid">'+esc(t.wxid)+'</span>';
  46 + h+='<button class="btn-del" onclick="del('+t.id+')">删除</button>';
  47 + h+='</div>';
  48 + }
  49 + el.innerHTML=h;
  50 + });
  51 +}
  52 +function add(){
  53 + var wxid=document.getElementById('wxid').value.trim();
  54 + var name=document.getElementById('name').value.trim();
  55 + if(!wxid){alert('请输入 wxid 或群id');return;}
  56 + fetch('/api/whitelist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({wxid:wxid,name:name})})
  57 + .then(function(r){return r.json()}).then(function(j){
  58 + if(j.ok){document.getElementById('wxid').value='';document.getElementById('name').value='';load();}
  59 + else{alert('失败: '+(j.error||''));}
  60 + });
  61 +}
  62 +function del(id){
  63 + fetch('/api/whitelist/'+id+'/delete',{method:'POST'}).then(function(r){return r.json()}).then(function(j){
  64 + if(j.ok){load();}else{alert('失败: '+(j.error||''));}
  65 + });
  66 +}
  67 +load();
  68 +</script>
  69 +{% endblock %}
wechat_plain/scripts/auto-reply/wechat-viewer-changes.md 0 → 100644
  1 +# wechat-viewer 集成改动(环节4 策略 + 审批/白名单 UI)
  2 +
  3 +> 落地在手机端 Flask 应用 `~/wechat-viewer`(该 app 有自己的 git 工作树,本文件只记录本次为
  4 +> 微信自动回复新增的部分;两个新模板全文见同目录 `viewer_templates/`)。
  5 +
  6 +## 新增数据表(`local_db.py` init_db)
  7 +
  8 +```sql
  9 +CREATE TABLE reply_whitelist ( -- 自动回复白名单
  10 + id INTEGER PK, kind TEXT, -- 'contact'|'group'
  11 + wxid TEXT UNIQUE, name TEXT, added_at TEXT);
  12 +
  13 +CREATE TABLE reply_queue ( -- 策略判定后落地
  14 + id INTEGER PK, msg_id INTEGER, talker TEXT, sender TEXT, sender_name TEXT,
  15 + kind TEXT, incoming TEXT, draft TEXT,
  16 + action TEXT, -- 'auto'|'review'
  17 + reason TEXT,
  18 + status TEXT, -- pending|sent|cancelled|auto_sent|failed
  19 + created_at TEXT, sent_at TEXT);
  20 +```
  21 +
  22 +`local_db.py` 新增辅助函数:`whitelist_list/add/remove/wxids`、`reply_add/list/get/update`。
  23 +
  24 +## 新增路由(`app.py`)
  25 +
  26 +| 路由 | 作用 |
  27 +|---|---|
  28 +| `GET /whitelist` | 白名单管理页 |
  29 +| `GET /api/whitelist` / `POST /api/whitelist` / `POST /api/whitelist/<id>/delete` | 白名单增删查(`xxx@chatroom` 自动识别为群) |
  30 +| `GET /approvals` | 自动回复审批页 |
  31 +| `GET /api/replies?status=` | 队列列表(pending / sent / auto_sent / cancelled / 全部) |
  32 +| `POST /api/reply/<id>/send` | 用(可编辑的)草稿发到会话 → `send_text_via_hook(talker,text)` → 标 sent/failed |
  33 +| `POST /api/reply/<id>/cancel` | 标 cancelled |
  34 +
  35 +发送复用 `~/.hermes/event_bus/wechat_hook_send.py` 的 `send_text_via_hook`(LSPosed 内部 API 广播 `SEND_MSG_INTERNAL`)。
  36 +
  37 +## 新增页面(`templates/`,见 viewer_templates/)
  38 +- `approvals.html`:待审批列表,每条含对方原文 + **可编辑草稿框** + 【✅发送】【🗑取消】;另有已发送/自动已发/已取消筛选 tab。
  39 +- `whitelist.html`:白名单增删列表。
  40 +
  41 +`base.html` 抽屉导航加两个入口:✅自动回复审批 / ⭐回复白名单。
  42 +
  43 +## 真机验证(2026-07-10)
  44 +- 页面 `/whitelist` `/approvals` 均 200。
  45 +- 白名单:加「测试好友」→ 列表可见 → 删除 OK。
  46 +- 审批:插入 pending → `/api/replies` 返回 → 点发送 → `send_text_via_hook` 广播到 filehelper(result=0, status=sent)→ 草稿按编辑后内容更新、状态转 sent、sent_at 记录。
  47 +- 取消:pending → cancel → status=cancelled。
  48 +- 测试数据已清空。
  49 +
  50 +## 待接(环节5 / 编排)
  51 +- **编排器**:把环节1 队列 `incoming.jsonl` → 上下文 → Hermes 生成 → 策略 `decide()`(白名单读 `reply_whitelist`)→ auto 直接 `send_text_via_hook` 并 `reply_add(status=auto_sent)` / review 写 `reply_add(status=pending)`。
  52 +- 群 @回复的 `@昵称` 前缀、原生引用气泡(A11y)。
wechat_plain/scripts/auto-reply/wechat_reply_policy.py 0 → 100644
  1 +#!/usr/bin/env python3
  2 +"""环节4 · 策略判定 —— 确定性安全闸(自动发 / 进审核 / 跳过)
  3 +
  4 +为什么是代码不是让 LLM 判:环节3 已验证「SOUL.md 里写的软约束会漏」(约饭案例模型仍
  5 +主动接话)。真正的自动/审核安全门必须是确定性、可审计、LLM 说服不动的硬规则。
  6 +
  7 +输入:watcher 事件 + Hermes 生成的草稿 + (可选)模型置信/should_reply 信号
  8 +输出:{action: auto|review|skip, reason}
  9 + skip —— 压根不该回(群里没@我、公众号、系统消息、自己发的、模型说别回)
  10 + auto —— 白名单 + 文本 + 无敏感词 + 总开关开 → 直接发(环节5)
  11 + review —— 其余一律进审核队列 pending.jsonl,人工在 wechat viewer 里点发送
  12 +
  13 +设计=default-deny:不在白名单、命中敏感词、低置信、拿不准 → 全部 review。
  14 +先保守起步(auto_send_enabled 默认 False = 全审核),跑顺了再对白名单放开自动。
  15 +
  16 +配置 ~/.hermes/wechat_reply/policy_config.json(首次自动生成默认值)。
  17 +"""
  18 +import json
  19 +import os
  20 +import time
  21 +
  22 +WORK_DIR = os.environ.get(
  23 + "WECHAT_REPLY_DIR",
  24 + "/data/data/com.termux/files/home/.hermes/wechat_reply")
  25 +CONFIG_FILE = f"{WORK_DIR}/policy_config.json"
  26 +PENDING_FILE = f"{WORK_DIR}/pending.jsonl" # 审核队列(环节5 viewer 读)
  27 +DECISION_LOG = f"{WORK_DIR}/decisions.jsonl"
  28 +
  29 +DEFAULT_CONFIG = {
  30 + "auto_send_enabled": False, # 总开关:False = 一切都进审核(保守起步)
  31 + "whitelist_contacts": [], # 允许自动发的 wxid(私聊)
  32 + "whitelist_groups": [], # 允许 @我 时自动发的 chatroom id
  33 + "min_confidence": 0.75, # 低于此→审核(模型给了置信才生效)
  34 + # 命中即强制 review(即使白名单也拉回人工)——钱/承诺/约时间/隐私
  35 + "block_keywords": [
  36 + "钱", "转账", "红包", "借", "还款", "付款", "打款", "汇款", "定金", "报价",
  37 + "价格", "多少钱", "合同", "签", "承诺", "保证", "担保",
  38 + "几点", "明天", "后天", "周末", "约", "见面", "面基", "碰头", "地址在",
  39 + "身份证", "银行卡", "验证码", "密码", "手机号", "住址",
  40 + ],
  41 +}
  42 +
  43 +# 非文本消息类型(草稿通常无意义)
  44 +NON_TEXT_TYPES = {3, 34, 43, 47, 42, 48, 49, 62, 10000}
  45 +SYSTEM_MARKERS = ("撤回了一条消息", "拍了拍", "拍一拍", "添加到通讯录", "以上是打招呼的内容")
  46 +
  47 +
  48 +def load_config():
  49 + try:
  50 + with open(CONFIG_FILE) as f:
  51 + cfg = json.load(f)
  52 + return {**DEFAULT_CONFIG, **cfg}
  53 + except (OSError, ValueError):
  54 + os.makedirs(WORK_DIR, exist_ok=True)
  55 + with open(CONFIG_FILE, "w") as f:
  56 + json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2)
  57 + return dict(DEFAULT_CONFIG)
  58 +
  59 +
  60 +def _hit_keyword(text, keywords):
  61 + for k in keywords:
  62 + if k and k in text:
  63 + return k
  64 + return None
  65 +
  66 +
  67 +def decide(event, draft, confidence=None, should_reply=True, self_wxid="", cfg=None):
  68 + """核心判定。返回 {action, reason, hit?}。纯函数、无副作用(写队列在 route())。"""
  69 + cfg = cfg or load_config()
  70 + kind = event.get("kind", "")
  71 + sender = event.get("sender") or event.get("talker") or ""
  72 + talker = event.get("talker", "")
  73 + body = event.get("body", "") or ""
  74 + mtype = event.get("type", 0)
  75 +
  76 + # ── skip 区:压根不回 ──
  77 + if sender and self_wxid and sender == self_wxid:
  78 + return {"action": "skip", "reason": "self:自己发的"}
  79 + if kind == "group_other":
  80 + return {"action": "skip", "reason": "group:群里没@我也没引用我"}
  81 + if str(talker).startswith("gh_") or str(sender).startswith("gh_"):
  82 + return {"action": "skip", "reason": "official:公众号/服务号推送"}
  83 + if any(m in body for m in SYSTEM_MARKERS):
  84 + return {"action": "skip", "reason": "system:系统消息(撤回/拍一拍/加好友)"}
  85 + if not should_reply:
  86 + return {"action": "skip", "reason": "model:模型判定无需回复"}
  87 + if not (draft or "").strip():
  88 + if mtype in NON_TEXT_TYPES:
  89 + return {"action": "skip", "reason": f"nontext:非文本(type={mtype})且无草稿"}
  90 + return {"action": "skip", "reason": "empty:草稿为空"}
  91 +
  92 + # ── 硬安全override:命中敏感词一律 review(白名单也拉回)──
  93 + hit = _hit_keyword(body + "\n" + (draft or ""), cfg["block_keywords"])
  94 + if hit:
  95 + return {"action": "review", "reason": f"sensitive:命中敏感词「{hit}」", "hit": hit}
  96 +
  97 + # ── 低置信 → review ──
  98 + if confidence is not None and confidence < cfg["min_confidence"]:
  99 + return {"action": "review", "reason": f"lowconf:置信{confidence}<{cfg['min_confidence']}"}
  100 +
  101 + # ── 白名单 + 总开关 → auto ──
  102 + is_group = event.get("isGroup") or str(talker).endswith("@chatroom")
  103 + whitelisted = (
  104 + (not is_group and sender in cfg["whitelist_contacts"]) or
  105 + (is_group and talker in cfg["whitelist_groups"])
  106 + )
  107 + if whitelisted and cfg["auto_send_enabled"]:
  108 + return {"action": "auto", "reason": "whitelist:白名单+总开关开→直接发"}
  109 +
  110 + # ── default-deny:其余全部人工审核 ──
  111 + if not cfg["auto_send_enabled"]:
  112 + return {"action": "review", "reason": "conservative:总开关关(全审核)"}
  113 + return {"action": "review", "reason": "notwhitelist:不在白名单→人工"}
  114 +
  115 +
  116 +def route(event, draft, decision, meta=None):
  117 + """按判定落地:review→写审核队列 pending.jsonl;一切→记 decisions.jsonl 审计。"""
  118 + os.makedirs(WORK_DIR, exist_ok=True)
  119 + rec = {
  120 + "ts": int(time.time()),
  121 + "msgId": event.get("msgId"),
  122 + "talker": event.get("talker"),
  123 + "sender": event.get("sender"),
  124 + "kind": event.get("kind"),
  125 + "action": decision["action"],
  126 + "reason": decision["reason"],
  127 + "draft": draft,
  128 + **(meta or {}),
  129 + }
  130 + with open(DECISION_LOG, "a") as f:
  131 + f.write(json.dumps(rec, ensure_ascii=False) + "\n")
  132 + if decision["action"] == "review":
  133 + item = {**rec, "status": "pending"}
  134 + with open(PENDING_FILE, "a") as f:
  135 + f.write(json.dumps(item, ensure_ascii=False) + "\n")
  136 + return rec
  137 +
  138 +
  139 +# ── 自检 demo(lazy 规则:留一个可跑的检查)──
  140 +def _demo():
  141 + cfg = dict(DEFAULT_CONFIG)
  142 + cfg["whitelist_contacts"] = ["wxid_friend"]
  143 + cfg["auto_send_enabled"] = True
  144 + cases = [
  145 + ("陌生猎头(非白名单)", {"kind": "private", "talker": "wxid_x", "sender": "wxid_x",
  146 + "body": "有急招岗位吗", "type": 1}, "暂不考虑,谢谢", None, "review"),
  147 + ("白名单好友闲聊", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend",
  148 + "body": "在干嘛", "type": 1}, "摸鱼呢哈哈", None, "auto"),
  149 + ("白名单好友约饭(敏感词)", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend",
  150 + "body": "明天中午一起吃饭?", "type": 1}, "可以啊几点", None, "review"),
  151 + ("白名单好友谈钱", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend",
  152 + "body": "借我5000周转下", "type": 1}, "行", None, "review"),
  153 + ("群里没@我", {"kind": "group_other", "talker": "x@chatroom", "sender": "wxid_a",
  154 + "isGroup": True, "body": "哈哈", "type": 1}, "草稿", None, "skip"),
  155 + ("公众号推送", {"kind": "private", "talker": "gh_abc", "sender": "gh_abc",
  156 + "body": "<msg>文章</msg>", "type": 49}, "", None, "skip"),
  157 + ("撤回系统消息", {"kind": "group_at_me", "talker": "x@chatroom", "sender": "wxid_a",
  158 + "body": "\"张三\" 撤回了一条消息", "type": 10000}, "", None, "skip"),
  159 + ("低置信", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend",
  160 + "body": "你觉得呢", "type": 1}, "嗯", 0.4, "review"),
  161 + ]
  162 + ok = True
  163 + for name, ev, draft, conf, expect in cases:
  164 + d = decide(ev, draft, confidence=conf, cfg=cfg)
  165 + flag = "OK " if d["action"] == expect else "FAIL"
  166 + if d["action"] != expect:
  167 + ok = False
  168 + print(f"[{flag}] {name}: 期望={expect} 实得={d['action']} ({d['reason']})")
  169 + print("=== ALL PASS ===" if ok else "=== 有 FAIL ===")
  170 + return ok
  171 +
  172 +
  173 +if __name__ == "__main__":
  174 + import sys
  175 + if "--demo" in sys.argv:
  176 + sys.exit(0 if _demo() else 1)
  177 + print("用法: wechat_reply_policy.py --demo (或作为库 import decide/route)")