diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index 82f6ace..39c7ad2 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -1377,7 +1377,10 @@ def promotion_review_approve(episode_id: int): user = get_current_user() or {} username = user.get('username', 'web_admin') approver_hash = hash_human_approver(username) - insight_id = promotion_gate.promote(episode_id) + insight_id = promotion_gate.promote( + episode_id, + human_approver=approver_hash, + ) if insight_id: return jsonify({'ok': True, 'insight_id': insight_id, 'approver': approver_hash}) return jsonify({'ok': False, 'error': 'promote failed'}), 500 @@ -1390,8 +1393,16 @@ def promotion_review_approve(episode_id: int): def promotion_review_reject(episode_id: int): """Web 介面「拒絕」按鈕""" try: - from services.learning_pipeline import promotion_gate - ok = promotion_gate.reject(episode_id, 'rejected_human', detail='web admin reject') + from services.learning_pipeline import promotion_gate, hash_human_approver + user = get_current_user() or {} + username = user.get('username', 'web_admin') + approver_hash = hash_human_approver(username) + ok = promotion_gate.reject( + episode_id, + 'rejected_human', + detail='web admin reject', + human_approver=approver_hash, + ) return jsonify({'ok': ok}) except Exception as e: return jsonify({'ok': False, 'error': str(e)[:200]}), 500 diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index e068a6c..c3370d3 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -8768,7 +8768,10 @@ def telegram_webhook(): approver_hash = hash_human_approver(str(cq_from_id or '')) if action == 'pg_ok': # 人工通過 → 直接 promote(不重跑 4 stage) - insight_id = promotion_gate.promote(episode_id) + insight_id = promotion_gate.promote( + episode_id, + human_approver=approver_hash, + ) ack = ( f"已晉升至 ai_insights #{insight_id}" if insight_id else "晉升失敗(已 log)" @@ -8776,7 +8779,8 @@ def telegram_webhook(): else: promotion_gate.reject( episode_id, 'rejected_human', - detail=f'human_approver={approver_hash}', + detail='human rejected via Telegram', + human_approver=approver_hash, ) ack = "已駁回(rejected_human)" sys_log.info( diff --git a/services/learning_pipeline.py b/services/learning_pipeline.py index eda4764..5ad86b3 100644 --- a/services/learning_pipeline.py +++ b/services/learning_pipeline.py @@ -455,7 +455,7 @@ class PromotionGate: return self._stage_4_review(episode) - def promote(self, episode_id: int) -> Optional[int]: + def promote(self, episode_id: int, human_approver: Optional[str] = None) -> Optional[int]: """執行晉升:寫 ai_insights + 更新 learning_episodes.{insight_id, promotion_status}。 Returns: @@ -506,10 +506,15 @@ class PromotionGate: UPDATE learning_episodes SET promotion_status = 'approved', insight_id = :insight_id, + human_approver = COALESCE(:human_approver, human_approver), reviewed_at = NOW() WHERE id = :id """), - {'insight_id': insight_id, 'id': episode_id}, + { + 'insight_id': insight_id, + 'human_approver': human_approver, + 'id': episode_id, + }, ) session.commit() logger.info( @@ -526,12 +531,19 @@ class PromotionGate: logger.error("[PromotionGate] promote failed (episode_id=%s): %s", episode_id, exc) return None - def reject(self, episode_id: int, reason: str, detail: Optional[str] = None) -> bool: + def reject( + self, + episode_id: int, + reason: str, + detail: Optional[str] = None, + human_approver: Optional[str] = None, + ) -> bool: """拒絕晉升:標 promotion_status='rejected_*' + rejected_reason。 Args: reason: rejected_quality / rejected_hallucination / rejected_duplicate / rejected_human detail: 補充說明(會與 reason 拼成 rejected_reason 文本) + human_approver: 已 hash 後的人工審核者識別;不存 PII 原文。 """ valid_statuses = ( 'rejected_quality', @@ -555,10 +567,16 @@ class PromotionGate: UPDATE learning_episodes SET promotion_status = :status, rejected_reason = :rej_reason, + human_approver = COALESCE(:human_approver, human_approver), reviewed_at = NOW() WHERE id = :id """), - {'status': reason, 'rej_reason': full_reason, 'id': episode_id}, + { + 'status': reason, + 'rej_reason': full_reason, + 'human_approver': human_approver, + 'id': episode_id, + }, ) session.commit() return True diff --git a/tests/test_promotion_gate.py b/tests/test_promotion_gate.py index 9c64aab..7edf034 100644 --- a/tests/test_promotion_gate.py +++ b/tests/test_promotion_gate.py @@ -303,6 +303,24 @@ class TestPromote: assert fake_session.execute.call_count >= 2 fake_session.commit.assert_called_once() + def test_promote_records_human_approver_hash(self, monkeypatch): + from services.learning_pipeline import PromotionGate + + ep = _fake_episode(quality_score=0.9, weight=0.5) + _patch_load_episode(monkeypatch, ep) + + fake_row = MagicMock() + fake_row.__getitem__.return_value = 555 + fake_session = MagicMock() + fake_session.execute.return_value.fetchone.return_value = fake_row + monkeypatch.setattr('database.manager.get_session', lambda: fake_session) + + gate = PromotionGate() + assert gate.promote(1, human_approver='abc123ef') == 555 + + update_params = fake_session.execute.call_args_list[1].args[1] + assert update_params['human_approver'] == 'abc123ef' + def test_promote_episode_not_found_returns_none(self, monkeypatch): from services.learning_pipeline import PromotionGate _patch_load_episode(monkeypatch, None) @@ -337,6 +355,24 @@ class TestRejectAndMark: assert ok is True fake_session.commit.assert_called_once() + def test_reject_records_human_approver_hash(self, monkeypatch): + from services.learning_pipeline import PromotionGate + + fake_session = MagicMock() + monkeypatch.setattr('database.manager.get_session', lambda: fake_session) + + gate = PromotionGate() + ok = gate.reject( + 1, + 'rejected_human', + detail='manual reject', + human_approver='abc123ef', + ) + + assert ok is True + params = fake_session.execute.call_args.args[1] + assert params['human_approver'] == 'abc123ef' + def test_reject_invalid_reason_returns_false(self): from services.learning_pipeline import PromotionGate gate = PromotionGate()