Python 复刻 Claude Duet

主动式 Agent 四件套

定时晨报、后台巡检、危险动作审批、出口核验——从「你问我答」到「我主动干活」,中间隔的不是模型能力,是这四件工程套件。约 200 行 Python,完整代码本页可复制。

调度 · 什么时候跑

APScheduler 钉死 Asia/Shanghai 时区(容器默认 UTC,hour=8 会变成北京 16:30);max_instances=1 + coalesce=True 防任务堆叠。

幂等 · 跑没跑过

SQLite UNIQUE 约束 + INSERT OR IGNORE 原子占坑:进程重启不会重发晨报;抢到锁却崩溃时用 mark_failed 释放,任务不会永久跳过。

审批 · 能不能做

Claude Duet 最值得抄的设计:不可逆动作(发邮件/删文件/改订单)不自主执行,落盘成待审批工单,人工放行,全程留痕防重复审批。

核验 · 说得对不对

主动式内容传播半径是整个群,错的晨报不能只靠读者发现。出口前逐句过 HallucC /detect 事实核验,存疑句子标注后再发。

出口核验 · 在线体验

这是四件套的最后一道门:主动式 Agent 生成的内容,发给所有人之前先逐句过一遍事实核验。下面是一份晨报样例,点击按钮看核验结果。

完整代码 · agent.py

四件套全部在一个文件里。依赖:pip install apscheduler pytz requests;配好 AIHCC_API_KEY 和 LLM key 即可运行(不配 LLM key 也能跑通骨架)。

"""
主动式 Agent 四件套:调度 + 幂等 + 审批 + 核验
环境:Python 3.10+,依赖 pip install apscheduler pytz requests
环境变量:AIHCC_API_KEY(https://aihcc.cloud 注册即得)/ DEEPSEEK_API_KEY(可选,不配走离线演示)
"""
import datetime
import json
import os
import re
import sqlite3
import uuid

import pytz
import requests
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger

TZ = pytz.timezone('Asia/Shanghai')
DB = 'agent_tasks.db'
PENDING = 'pending_actions.jsonl'


# ---------- 配置:先加载 .env 再读变量(顺序反了就拿不到 key)----------
def load_env(path: str) -> bool:
    """极简 .env 加载器;已存在的环境变量优先,不覆盖。
    KEY= 空值行跳过:setdefault 会让它抢先占位,顶掉后面文件里真正的值。"""
    if not os.path.isfile(path):
        return False
    with open(path, encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith('#') or '=' not in line:
                continue
            k, v = line.split('=', 1)
            k, v = k.strip(), v.strip().strip('"\'')
            if not k or not v:
                continue
            os.environ.setdefault(k, v)
    return True


ENV_FILE = os.environ.get('ENV_FILE', '.env')
LOCAL_ENV = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env.local')
for _p in (LOCAL_ENV, ENV_FILE):      # 先加载本地:setdefault 不覆盖,靠前的生效
    load_env(_p)

AIHCC_BASE = os.environ.get('AIHCC_BASE') or 'https://aihcc.cloud/api'
LLM_BASE = os.environ.get('LLM_BASE') or 'https://api.deepseek.com/v1'
LLM_MODEL = os.environ.get('LLM_MODEL') or 'deepseek-v4-pro'


def _find_key(candidates, patterns):
    """先精确名,再正则兜底。返回 (变量名, 值)。
    每次调用都现取 os.environ——import 期固化成全局变量时 .env 还没加载。"""
    for name in candidates:
        v = os.environ.get(name)
        if v:
            return name, v
    for k, v in sorted(os.environ.items()):
        if v and any(re.fullmatch(p, k) for p in patterns):
            return k, v
    return '', ''


def llm_key():
    """LLM_BASE 指向哪家,就找那家的变量。改 LLM_BASE 即可换供应商。"""
    if 'deepseek' in LLM_BASE.lower():
        return _find_key(('DEEPSEEK_API_KEY',), ('DEEPSEEK.*KEY',))
    return _find_key(('OPENAI_API_KEY',), ('OPENAI.*KEY',))


def hallucc_key():
    return _find_key(('AIHCC_API_KEY', 'HALLUC_API_KEY'),
                     ('AIHCC.*KEY', 'HALLUC.*KEY'))


def now() -> datetime.datetime:
    """统一取 Asia/Shanghai 时间,别用裸的 datetime.now()。"""
    return datetime.datetime.now(TZ)


def notify(text: str):
    """推送通知:替换成企业微信/钉钉群机器人 webhook 即可。"""
    print(f'[NOTIFY] {text}')


# ---------- 四件套之四:核验(出口安检,HallucC 公开接口)----------
MAX_CHARS = 50_000     # /detect 的 text 上限,超出部分会被服务端截断


def verify_brief(text: str, strict: bool = False, domain: str = 'general') -> dict:
    """调 HallucC /detect 做逐句事实核验。
    契约要点(实测):POST /detect(没有 /v1),鉴权 Authorization: Bearer hc_xxx;
    存疑数是 summary.red(没有 doubtful);没有 annotated_text,标注要自己渲染。"""
    _, key = hallucc_key()
    resp = requests.post(f'{AIHCC_BASE}/detect',
                         headers={'Authorization': f'Bearer {key}'},
                         json={'text': text[:MAX_CHARS], 'strict': strict,
                               'domain': domain},
                         timeout=120)
    resp.raise_for_status()
    data = resp.json()
    # 200 但 body 是错误结构(额度/内部错)时,别当成「核验通过」
    if not isinstance(data.get('summary'), dict) or not data.get('claims'):
        raise RuntimeError(f"/detect 返回了非检测结构: "
                           f"{json.dumps(data, ensure_ascii=False)[:200]}")
    return data


MARKS = {'supported': '🟢', 'refuted': '🔴', 'unverified': '🟡'}


def compose_report(report: dict, raw: str) -> str:
    """把 /detect 响应渲染成可读晨报(接口本身不返回标注文本)。"""
    s = report.get('summary') or {}
    head = (f"晨报核验:共 {s.get('total', 0)} 句 | "
            f"🟢可信 {s.get('green', 0)} 🟡疑似 {s.get('yellow', 0)} "
            f"🔴存疑 {s.get('red', 0)} | 幻觉风险 {s.get('hallucination_risk', 0):.2f}")
    lines = [head, '', raw, '', '—— 逐句核验 ——']
    for c in report.get('claims') or []:
        v = c.get('verification') or {}
        mark = MARKS.get(v.get('status'), '⚪')
        line = f"{mark} {c.get('claim', {}).get('text', '')}" \
               f"(把握 {v.get('confidence', 0):.0%})"
        srcs = v.get('sources') or []
        if srcs:
            line += f" 依据:{srcs[0].get('title', '')}"
        fix = (c.get('correction') or {}).get('corrected')
        if fix:
            line += f"\n  建议改为:{fix}"
        lines.append(line)
    return '\n'.join(lines)


# ---------- LLM(OpenAI 兼容;未配 key 返回离线演示文本,先跑通骨架)----------
def agent_run(prompt: str, output_json: bool = False):
    """返回类型与 output_json 一致:True -> dict,False -> str。"""
    _, key = llm_key()
    if not key:
        off = ('{"level":"alert","summary":"订单积压 37 单,超过阈值 20"}' if output_json
               else '今日要闻:(离线演示文本,未配置 LLM key)')
        return json.loads(off) if output_json else off
    payload = {'model': LLM_MODEL, 'temperature': 0.2,
               'messages': [{'role': 'user', 'content': prompt}]}
    if output_json:
        payload['response_format'] = {'type': 'json_object'}
    resp = requests.post(f'{LLM_BASE}/chat/completions',
                         headers={'Authorization': f'Bearer {key}'},
                         json=payload, timeout=60)
    resp.raise_for_status()
    content = resp.json()['choices'][0]['message']['content']
    return json.loads(content) if output_json else content


# ---------- 四件套之二:幂等(SQLite 原子占坑)----------
def init_db():
    with sqlite3.connect(DB) as conn:
        conn.execute('''CREATE TABLE IF NOT EXISTS task_log(
            task TEXT, idem_key TEXT,
            status TEXT DEFAULT 'done',
            created_at TEXT DEFAULT (datetime('now','localtime')),
            UNIQUE(task, idem_key)
        )''')
        conn.commit()


def acquire_lock(task: str, idem_key: str) -> bool:
    """原子抢占:INSERT 成功=拿到执行权;UNIQUE 冲突=已执行过。"""
    conn = sqlite3.connect(DB)
    try:
        cur = conn.execute('INSERT OR IGNORE INTO task_log(task, idem_key) VALUES(?,?)',
                           (task, idem_key))
        conn.commit()
        return cur.rowcount == 1
    finally:
        conn.close()          # with sqlite3.connect() 只 commit/rollback,不负责 close


def brief_key() -> str:
    return f'brief-{now().date().isoformat()}'     # 粒度到天:一天最多一份晨报


def patrol_key() -> str:
    return f"patrol-{now().strftime('%Y%m%d%H%M')}"  # 粒度到分钟:窗口过了就翻篇


def mark_failed(task: str, idem_key: str) -> None:
    """执行失败时释放幂等锁,让下一个窗口有机会重试。
    否则「抢到锁 -> 中途崩了」会变成「任务永久跳过」,而且没有任何提示。"""
    conn = sqlite3.connect(DB)
    try:
        conn.execute("DELETE FROM task_log WHERE task=? AND idem_key=? AND status='done'",
                     (task, idem_key))
        conn.commit()
    finally:
        conn.close()


# ---------- 四件套之三:审批(不可逆动作转人工工单)----------
RISK_LEVELS = {'query_data': 'low', 'generate_report': 'low',
               'send_email': 'high', 'delete_file': 'high', 'update_order': 'high'}
HANDLERS = {}


def handler(name):
    """装饰器:登记 Agent 可调用的动作实现。"""
    def deco(fn):
        HANDLERS[name] = fn
        return fn
    return deco


@handler('query_data')
def _query_data():
    return {'rows': [], 'note': '接你自己的数据源'}


@handler('generate_report')
def _generate_report(title=''):
    return {'report': f'报告:{title}'}


@handler('send_email')
def _send_email(to='', subject='', body=''):
    return {'sent': False, 'to': to, 'note': '演示:不真的发邮件'}


@handler('delete_file')
def _delete_file(path=''):
    return {'deleted': False, 'path': path, 'note': '演示:不真的删'}


@handler('update_order')
def _update_order(order_id=None, status=''):
    return {'updated': False, 'order_id': order_id}


def guarded_call(agent_id: str, tool: str, args: dict):
    """Agent 所有动作的统一出口:低危直接执行,高危转人工审批。"""
    if RISK_LEVELS.get(tool, 'high') == 'high':     # 未登记默认高危
        return request_approval(agent_id, tool, args)
    if tool not in HANDLERS:                        # 登记成 low 却没实现 -> 别崩
        return {'error': f'no handler for low-risk tool {tool!r}'}
    return HANDLERS[tool](**args)


def request_approval(agent_id: str, tool: str, args: dict) -> str:
    """高危动作不执行,落盘成待审批工单并推送提醒,返回工单号。"""
    ticket = uuid.uuid4().hex[:8]
    record = {'id': ticket, 'agent': agent_id, 'tool': tool, 'args': args,
              'status': 'pending', 'created_at': now().isoformat(timespec='seconds')}
    with open(PENDING, 'a', encoding='utf-8') as f:
        f.write(json.dumps(record, ensure_ascii=False) + '\n')
    notify(f'【待审批 {ticket}】{tool} {args}')
    return ticket


def review(ticket: str, approve: bool) -> str:
    """人工审批入口:只有 pending 状态才真正放行,状态全程留痕。"""
    if not os.path.exists(PENDING):
        return 'not_found'
    with open(PENDING, encoding='utf-8') as f:
        entries = [json.loads(line) for line in f if line.strip()]
    outcome = 'not_found'
    for e in entries:
        if e['id'] != ticket:
            continue
        if e['status'] != 'pending':
            outcome = f"already_{e['status']}"        # 防重复审批
            break
        if approve:
            if e['tool'] not in HANDLERS:
                e['status'] = 'failed'                # 放行但没人执行 -> 别假装成功
                outcome = e['status']
                break
            HANDLERS[e['tool']](**e['args'])
            e['status'] = 'approved'
        else:
            e['status'] = 'rejected'
        e['reviewed_at'] = now().isoformat(timespec='seconds')
        outcome = e['status']
        break
    # 原子替换:先写临时文件再 rename,进程中途挂掉不会把审计日志截断
    tmp = PENDING + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as f:
        for e in entries:
            f.write(json.dumps(e, ensure_ascii=False) + '\n')
    os.replace(tmp, PENDING)
    return outcome


# ---------- 主动任务:晨报(核验后才发)+ 巡检 ----------
def morning_brief():
    key = brief_key()
    if not acquire_lock('morning_brief', key):
        return                          # 幂等锁:今天已生成过,直接跳过
    try:
        brief = agent_run('汇总今日行业要闻与团队待办,生成 300 字晨报')
        if not isinstance(brief, str):
            raise RuntimeError(f'LLM 返回了非文本结果: {type(brief).__name__}')
    except Exception as exc:
        mark_failed('morning_brief', key)   # 抢了锁却没跑成 -> 释放,允许重试
        notify(f'[晨报生成失败] {exc.__class__.__name__}: {exc}')
        return

    try:
        report = verify_brief(brief)
        if (report.get('summary') or {}).get('total', 0) == 0:
            # 一句话都没抽出来 = 其实没核验过,不能当「核验通过」
            text = f'⚠️ 本次未抽到任何可核验声明,本晨报未经有效核验\n\n{brief}'
        else:
            text = compose_report(report, brief)
            risky = (report.get('summary') or {}).get('red', 0)
            if risky:
                text = (f'⚠️ 本晨报含 {risky} 句存疑内容,已逐句标注,请过目后再转发\n\n{text}')
    except (requests.RequestException, ValueError, RuntimeError,
            PermissionError, OSError) as exc:
        # 核验不可用:不静默放行,明确标注「未经核验」。
        # ValueError 必须收——resp.json() 抛的 JSONDecodeError 是 ValueError 子类,
        # 不是 requests.RequestException(实测 issubclass 为 False)。
        text = f'⚠️ 核验服务不可用({exc.__class__.__name__}),本晨报未经核验\n\n{brief}'
    notify(text)


def patrol():
    """每 15 分钟巡检:Agent 查数据源,仅触发阈值时才通知。"""
    key = patrol_key()
    if not acquire_lock('patrol', key):
        return
    try:
        result = agent_run('巡检数据源,异常时输出 {"level":"alert","summary":"..."}',
                           output_json=True)
    except Exception:
        mark_failed('patrol', key)
        return
    if not isinstance(result, dict):
        mark_failed('patrol', key)
        return
    if result.get('level') == 'alert':
        notify(f"[巡检告警] {result.get('summary', '')}")


# ---------- 四件套之一:调度(时区钉死,任务防堆叠)----------
def build_scheduler() -> BlockingScheduler:
    scheduler = BlockingScheduler(timezone=TZ)
    scheduler.add_job(morning_brief, CronTrigger(hour=8, minute=30, timezone=TZ),
                      id='brief', max_instances=1, coalesce=True)
    scheduler.add_job(patrol, 'interval', minutes=15, timezone=TZ,
                      id='patrol', max_instances=1, coalesce=True)
    return scheduler


def next_fire(trigger, prev=None, now_dt=None):
    """触发器自身的下一次触发时刻——start() 之前也能算。
    别用 get_job('brief').next_run_time:job 在 start() 前是 pending 的,会 AttributeError。"""
    return trigger.get_next_fire_time(prev, now_dt or now())


if __name__ == '__main__':
    init_db()
    scheduler = build_scheduler()
    print('主动式 Agent 已启动:晨报每天 08:30(Asia/Shanghai),巡检每 15 分钟')
    scheduler.start()      # 阻塞式启动,适合单职责的常驻进程

让你的 Agent 说的每句话都有据可依

注册即得 API Key,免费额度每天 3 次。一个 POST 接入核验,查不到依据的句子老实标注「无法核实」,而不是硬判成错。

免费注册