"""本地 SQLite 管理层 — 加工数据中心"""
import sqlite3
import re
import config
from pathlib import Path
def get_conn() -> sqlite3.Connection:
conn = sqlite3.connect(config.LOCAL_DB)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db():
"""初始化本地 DB 表结构"""
conn = get_conn()
conn.executescript("""
-- 消息处理追踪
CREATE TABLE IF NOT EXISTS processed_messages (
msg_id INTEGER PRIMARY KEY,
chatroom_id TEXT,
talker TEXT,
msg_type INTEGER,
processed_at TEXT DEFAULT (datetime('now','localtime'))
);
-- 群聊日总结
CREATE TABLE IF NOT EXISTS chat_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chatroom_id TEXT,
date TEXT,
summary TEXT,
msg_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
-- 文章队列 (旧表,保留兼容)
CREATE TABLE IF NOT EXISTS article_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_msg_id INTEGER,
chatroom_id TEXT,
talker TEXT,
url TEXT,
title TEXT,
status TEXT DEFAULT 'pending',
extracted_at TEXT DEFAULT (datetime('now','localtime'))
);
-- 文章主表 (一篇一行,URL 去重)
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT UNIQUE,
title TEXT,
first_seen_at TEXT DEFAULT (datetime('now','localtime'))
);
-- 文章分享记录 (哪个群、谁、几点几分分享了哪篇)
CREATE TABLE IF NOT EXISTS article_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER REFERENCES articles(id),
chatroom_id TEXT,
talker TEXT,
source_msg_id INTEGER,
msg_create_time INTEGER, -- 原始消息时间戳(ms)
shared_at TEXT DEFAULT (datetime('now','localtime'))
);
-- 图片分析
CREATE TABLE IF NOT EXISTS image_analysis (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_msg_id INTEGER,
chatroom_id TEXT,
talker TEXT,
image_path TEXT,
ocr_text TEXT,
description TEXT,
analyzed_at TEXT DEFAULT (datetime('now','localtime'))
);
-- 日报
CREATE TABLE IF NOT EXISTS daily_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chatroom_id TEXT,
chatroom_name TEXT,
report_date TEXT,
content TEXT,
msg_count INTEGER DEFAULT 0,
image_count INTEGER DEFAULT 0,
article_count INTEGER DEFAULT 0,
dedup_key TEXT,
article_url TEXT,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
-- 日报图片
CREATE TABLE IF NOT EXISTS report_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id INTEGER,
image_path TEXT,
style TEXT DEFAULT 'wsj',
created_at TEXT DEFAULT (datetime('now','localtime'))
);
-- Hermes 子进程任务追踪
CREATE TABLE IF NOT EXISTS hermes_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT UNIQUE NOT NULL,
job_type TEXT NOT NULL,
job_name TEXT,
status TEXT DEFAULT 'running',
log_file TEXT,
started_at TEXT DEFAULT (datetime('now','localtime')),
completed_at TEXT
);
-- 学习吸收管线
CREATE TABLE IF NOT EXISTS learn_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_url TEXT,
title TEXT,
dedup_key TEXT,
learn_type TEXT NOT NULL,
learn_target TEXT,
learn_content TEXT,
outline TEXT,
confidence TEXT DEFAULT 'medium',
score INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending_review',
error_msg TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
reviewed_at TEXT
);
-- 学习结果记录(自学习机制的核心输出)
CREATE TABLE IF NOT EXISTS learning_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
learn_queue_id INTEGER REFERENCES learn_queue(id),
article_url TEXT,
article_title TEXT,
outline TEXT,
result_type TEXT NOT NULL,
result_summary TEXT,
created_skill TEXT,
updated_skill TEXT,
learned_methodology TEXT,
verification_result TEXT,
architecture_change TEXT,
status TEXT DEFAULT 'applied',
human_feedback TEXT,
auto_applied INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
-- 自动回复白名单(在此名单内的人/群可被直接自动回复)
CREATE TABLE IF NOT EXISTS reply_whitelist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- 'contact' | 'group'
wxid TEXT NOT NULL UNIQUE, -- wxid 或 chatroom id
name TEXT DEFAULT '',
role TEXT DEFAULT 'member', -- 'admin'=完整hermes工具 | 'member'=简单hermes -z
added_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS reply_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
msg_id INTEGER,
talker TEXT, -- 会话对象(发送目标)
sender TEXT, -- 实际发言人 wxid
sender_name TEXT DEFAULT '',
kind TEXT, -- private/group_at_me/group_quote_me
incoming TEXT, -- 对方原文
draft TEXT, -- 生成的回复草稿
result_file TEXT, -- hermes 任务结果文件路径
action TEXT, -- 'auto' | 'review'
reason TEXT DEFAULT '', -- 判定理由
status TEXT DEFAULT 'pending', -- pending|ack_sent|report_ready|auto_sent|failed|cancelled
created_at TEXT DEFAULT (datetime('now','localtime')),
sent_at TEXT
);
-- AI 聊天:会话表
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT DEFAULT '新对话',
created_at TEXT DEFAULT (datetime('now','localtime')),
updated_at TEXT DEFAULT (datetime('now','localtime'))
);
-- AI 聊天:消息表
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('user','assistant','system')),
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
""")
conn.commit()
# 为已有表补加列(兼容旧表)
migrations = [
"ALTER TABLE daily_reports ADD COLUMN dedup_key TEXT",
"ALTER TABLE daily_reports ADD COLUMN article_url TEXT",
"ALTER TABLE learn_queue ADD COLUMN outline TEXT",
"ALTER TABLE learn_queue ADD COLUMN score INTEGER DEFAULT 0",
"ALTER TABLE learn_queue ADD COLUMN priority INTEGER DEFAULT 0",
"ALTER TABLE reply_whitelist ADD COLUMN role TEXT DEFAULT 'member'",
# 2026-10-04:CREATE TABLE articles 未含 dedup_key,线上表却有 ⇒ 走迁移补列。
# 新库也必须补,否则 article_upsert/状态机按 dedup_key 匹配会失败。
"ALTER TABLE articles ADD COLUMN dedup_key TEXT",
# 2026-10-04 摘要状态机(需求 reczz28JC9dkrLDn):
# 区分「未排上摘要(pending)」与「反复摘不出来(failed)」,failed 不拦日报。
"ALTER TABLE articles ADD COLUMN summary_status TEXT DEFAULT 'pending'",
"ALTER TABLE articles ADD COLUMN summary_attempts INTEGER DEFAULT 0",
"ALTER TABLE articles ADD COLUMN summary_updated_at TEXT",
]
for sql in migrations:
try:
conn.execute(sql)
except sqlite3.OperationalError:
pass # 列已存在
# 一次性幂等回填(2026-10-04):已摘好的文章标记 'ok'。
# 只动 summary_status='pending' 的行,重复执行无副作用。
try:
conn.execute(
"""UPDATE articles SET summary_status = 'ok'
WHERE COALESCE(summary_status, 'pending') = 'pending'
AND (
dedup_key IN (SELECT dedup_key FROM daily_reports
WHERE report_date = 'summary' AND dedup_key IS NOT NULL
AND content NOT LIKE '%生成失败%' AND length(content) > 50)
OR url IN (SELECT article_url FROM daily_reports
WHERE report_date = 'summary' AND article_url IS NOT NULL
AND content NOT LIKE '%生成失败%' AND length(content) > 50)
)"""
)
conn.commit()
except Exception:
pass # 回填失败不阻塞启动
# 修复时区:UTC → 北京时间 (one-time migration, idempotent)
tz_fixes = [
# hermse_jobs: rebuild table with localtime default
("hermes_jobs",
"""CREATE TABLE hermes_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT UNIQUE NOT NULL,
job_type TEXT NOT NULL,
job_name TEXT,
status TEXT DEFAULT 'running',
log_file TEXT,
started_at TEXT DEFAULT (datetime('now','localtime')),
completed_at TEXT
)"""),
# daily_reports: rebuild table with localtime default
("daily_reports",
"""CREATE TABLE daily_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chatroom_id TEXT,
chatroom_name TEXT,
report_date TEXT,
content TEXT,
msg_count INTEGER DEFAULT 0,
image_count INTEGER DEFAULT 0,
article_count INTEGER DEFAULT 0,
dedup_key TEXT,
article_url TEXT,
created_at TEXT DEFAULT (datetime('now','localtime'))
)"""),
]
for table_name, create_sql in tz_fixes:
try:
# Check if table has old UTC default
old_schema = conn.execute(
f"SELECT sql FROM sqlite_master WHERE name='{table_name}'"
).fetchone()
if old_schema and "datetime('now','localtime')" not in (old_schema["sql"] or ""):
conn.execute(f"ALTER TABLE {table_name} RENAME TO {table_name}_old")
conn.execute(create_sql)
conn.execute(f"INSERT INTO {table_name} SELECT * FROM {table_name}_old")
conn.execute(f"DROP TABLE {table_name}_old")
# Shift existing timestamps from UTC to Beijing time
time_cols = ["started_at", "completed_at"] if table_name == "hermes_jobs" else ["created_at"]
for col in time_cols:
conn.execute(
f"UPDATE {table_name} SET {col} = datetime({col}, '+8 hours') "
f"WHERE {col} IS NOT NULL AND {col} LIKE '% 0_:%'"
)
conn.commit()
except Exception:
pass # already migrated or no data
# 用户认证
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at TEXT DEFAULT (datetime('now','localtime'))
)
""")
# 老库补 role 列(幂等)
cols = [r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()]
if 'role' not in cols:
conn.execute("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'")
# 默认管理员 admin / wechat2026
import hashlib
default_pw = hashlib.sha256("wechat2026".encode()).hexdigest()
conn.execute(
"INSERT OR IGNORE INTO users (username, password_hash) VALUES (?, ?)",
("admin", default_pw)
)
conn.commit()
# 加索引加速去重查询
try:
conn.execute("CREATE INDEX IF NOT EXISTS idx_daily_reports_dedup ON daily_reports(dedup_key, report_date)")
except Exception:
pass
conn.commit()
conn.close()
# ── 日报图片管理 ────────────────────────────────────
def report_image_list(limit: int = 100) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"""SELECT ri.*, r.chatroom_name, r.report_date
FROM report_images ri
LEFT JOIN daily_reports r ON ri.report_id = r.id
ORDER BY ri.id DESC LIMIT ?""",
(limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def report_image_add(report_id: int, image_path: str, style: str = "wsj") -> int:
conn = get_conn()
cur = conn.execute(
"INSERT INTO report_images (report_id, image_path, style) VALUES (?, ?, ?)",
(report_id, image_path, style)
)
conn.commit()
row_id = cur.lastrowid
conn.close()
return row_id
def report_image_delete(image_id: int):
conn = get_conn()
conn.execute("DELETE FROM report_images WHERE id = ?", (image_id,))
conn.commit()
conn.close()
# ── 日报管理 ────────────────────────────────────────
def report_list(limit: int = 100, report_type: str = "") -> list[dict]:
conn = get_conn()
if report_type == "digest":
# Daily digests: report_date is a date string like "2026-07-08"
rows = conn.execute(
"SELECT * FROM daily_reports WHERE report_date NOT IN ('summary','learn') ORDER BY id DESC LIMIT ?",
(limit,)
).fetchall()
elif report_type:
rows = conn.execute(
"SELECT * FROM daily_reports WHERE report_date = ? ORDER BY id DESC LIMIT ?",
(report_type, limit)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM daily_reports ORDER BY id DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def report_get(report_id: int) -> dict | None:
conn = get_conn()
row = conn.execute("SELECT * FROM daily_reports WHERE id = ?", (report_id,)).fetchone()
conn.close()
return dict(row) if row else None
def report_add(chatroom_id: str, chatroom_name: str, report_date: str,
content: str, msg_count: int = 0, image_count: int = 0,
article_count: int = 0, dedup_key: str = "",
article_url: str = "") -> int:
conn = get_conn()
cur = conn.execute(
"""INSERT INTO daily_reports (chatroom_id, chatroom_name, report_date, content,
msg_count, image_count, article_count, dedup_key, article_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(chatroom_id, chatroom_name, report_date, content,
msg_count, image_count, article_count,
dedup_key or None, article_url or None)
)
conn.commit()
row_id = cur.lastrowid
conn.close()
return row_id
def report_delete(report_id: int):
conn = get_conn()
conn.execute("DELETE FROM daily_reports WHERE id = ?", (report_id,))
conn.commit()
conn.close()
# ── 文章摘要去重查询 ────────────────────────────────
def find_article_summary(dedup_key: str, report_type: str) -> dict | None:
"""查某篇文章是否已有摘要/学习结果(排除失败记录)。
report_type: 'summary' 或 'learn'
"""
conn = get_conn()
row = conn.execute(
"""SELECT * FROM daily_reports
WHERE dedup_key = ? AND report_date = ?
AND content NOT LIKE '%生成失败%'
AND length(content) > 50
ORDER BY id DESC LIMIT 1""",
(dedup_key, report_type)
).fetchone()
conn.close()
return dict(row) if row else None
def find_article_summaries_batch(dedup_keys: list[str], report_type: str = "summary") -> dict[str, dict]:
"""批量查哪些 dedup_key 已有摘要。
返回 {dedup_key: {id, content, created_at}, ...}
report_type: 'summary' 或 'learn' 或 ''(查任何类型)
"""
if not dedup_keys:
return {}
conn = get_conn()
# 2026-08-28:非公众号链接(如 changdunovel 互动剧)dedup_key 就是 URL 本身,但
# gen_agg_digest 的 API 调用方在 _extract_dedup_key 得 None 时会以 "t:
" 传 key
# → 双通道匹配:dedup_key 精确 + 标题 LIKE。
dk_keys = [k for k in dedup_keys if not k.startswith("t:")]
t_keys = [k[2:] for k in dedup_keys if k.startswith("t:")]
result = {}
if dk_keys:
placeholders = ",".join(["?"] * len(dk_keys))
if report_type:
sql = f"""SELECT dedup_key, id, content, report_date, created_at
FROM daily_reports
WHERE dedup_key IN ({placeholders}) AND report_date = ?
AND content NOT LIKE '%生成失败%'
AND length(content) > 50
ORDER BY id DESC"""
rows = conn.execute(sql, [*dk_keys, report_type]).fetchall()
else:
sql = f"""SELECT dedup_key, id, content, report_date, created_at
FROM daily_reports
WHERE dedup_key IN ({placeholders})
AND content NOT LIKE '%生成失败%'
AND length(content) > 50
ORDER BY id DESC"""
rows = conn.execute(sql, dk_keys).fetchall()
for row in rows:
d = dict(row)
dk = d.pop("dedup_key")
if dk and dk not in result:
result[dk] = d
if t_keys:
# LIKE 粗筛候选:标题前 12 字 + 书名号内标题(分享标题常带动词前缀,如「跟我一起免费看《X》」)
like_vals = []
like_conds = []
for t in t_keys:
like_conds.append("content LIKE ?")
like_vals.append(f"%{t[:12]}%")
m_book = re.search(r"《(.+?)》", t)
if m_book:
like_conds.append("content LIKE ?")
like_vals.append(f"%{m_book.group(1)}%")
cond = " OR ".join(like_conds)
sql = f"""SELECT dedup_key, id, content, report_date, created_at
FROM daily_reports
WHERE report_date = 'summary' AND ({cond})
AND content NOT LIKE '%生成失败%'
AND length(content) > 50
ORDER BY id DESC"""
for row in conn.execute(sql, like_vals).fetchall():
d = dict(row)
c = d.get("content") or ""
first = c.split(chr(10))[0] if c else ""
m_title = re.match(r"^#\s*(.+?)摘要", first)
doc_title = m_title.group(1).strip() if m_title else ""
for t in t_keys:
# 双向包含:内容含标题 或 摘要首行书名标题是标题的子串(2026-08-28 柠萌萌互动剧)
if (t in c or (doc_title and doc_title in t)) and f"t:{t}" not in result:
result[f"t:{t}"] = d
break
conn.close()
return result
# ── 文章队列 ────────────────────────────────────────
def article_list(status: str = "pending", limit: int = 100) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"""SELECT * FROM article_queue WHERE status = ? ORDER BY id DESC LIMIT ?""",
(status, limit)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def article_add(source_msg_id: int, chatroom_id: str, talker: str,
url: str, title: str) -> int:
conn = get_conn()
cur = conn.execute(
"""INSERT INTO article_queue (source_msg_id, chatroom_id, talker, url, title)
VALUES (?, ?, ?, ?, ?)""",
(source_msg_id, chatroom_id, talker, url, title)
)
conn.commit()
row_id = cur.lastrowid
conn.close()
return row_id
def article_update_status(article_id: int, status: str):
conn = get_conn()
conn.execute(
"UPDATE article_queue SET status = ? WHERE id = ?",
(status, article_id)
)
conn.commit()
conn.close()
def article_find_by_url(url: str) -> dict | None:
"""Check if article URL already exists (dedup)."""
conn = get_conn()
row = conn.execute(
"SELECT * FROM article_queue WHERE url = ?", (url,)
).fetchone()
conn.close()
return dict(row) if row else None
def article_get_unsummarized(limit: int = 20) -> list[dict]:
"""Articles in article_queue that have no summary in daily_reports."""
conn = get_conn()
conn.row_factory = lambda c, r: dict(zip([col[0] for col in c.description], r))
rows = conn.execute(
"""SELECT a.id, a.url, a.title, a.talker, a.chatroom_id, a.extracted_at
FROM article_queue a
WHERE a.status = 'pending'
AND a.id NOT IN (
SELECT DISTINCT a2.id FROM article_queue a2
INNER JOIN daily_reports r ON r.article_url = a2.url AND r.report_date = 'summary'
)
ORDER BY a.id DESC LIMIT ?""",
(limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
# ── 新文章表 (articles + article_shares) ──────────────
def article_count() -> int:
conn = get_conn()
cnt = conn.execute("SELECT COUNT(*) FROM articles").fetchone()[0]
conn.close()
return cnt
def article_upsert(url: str, title: str, return_was_new: bool = False) -> int | tuple[int, bool]:
"""Insert or get article_id (dedup by dedup_key from biz|mid|idx|sn).
If return_was_new=True, returns (article_id, was_new)."""
dedup_key = _extract_dedup_key(url)
conn = get_conn()
if dedup_key:
row = conn.execute("SELECT id FROM articles WHERE dedup_key = ?", (dedup_key,)).fetchone()
if row:
conn.close()
return (row[0], False) if return_was_new else row[0]
else:
row = conn.execute("SELECT id FROM articles WHERE url = ?", (url,)).fetchone()
if row:
conn.close()
return (row[0], False) if return_was_new else row[0]
cur = conn.execute("INSERT INTO articles (url, title, dedup_key) VALUES (?, ?, ?)",
(url, title, dedup_key))
conn.commit()
rid = cur.lastrowid
conn.close()
return (rid, True) if return_was_new else rid
def _extract_dedup_key(url: str) -> str:
"""Extract canonical dedup key: biz|mid|idx|sn"""
if not url:
return ""
if "mp.weixin.qq.com" in url:
biz = re.search(r"__biz=([^&]+)", url)
mid = re.search(r"mid=(\d+)", url)
idx = re.search(r"idx=(\d+)", url)
sn = re.search(r"sn=([a-f0-9]+)", url)
if biz and mid and idx and sn:
return f"{biz.group(1)}|{mid.group(1)}|{idx.group(1)}|{sn.group(1)}"
return url
def article_share_add(article_id: int, chatroom_id: str, talker: str,
source_msg_id: int, msg_create_time: int):
"""Record that article was shared in a group at a specific time by a specific person."""
conn = get_conn()
conn.execute(
"""INSERT INTO article_shares (article_id, chatroom_id, talker, source_msg_id, msg_create_time)
VALUES (?, ?, ?, ?, ?)""",
(article_id, chatroom_id, talker, source_msg_id, msg_create_time)
)
conn.commit()
conn.close()
def share_exists(source_msg_id: int) -> bool:
"""Check if a share with this WeChat msgId already exists."""
conn = get_conn()
row = conn.execute(
"SELECT 1 FROM article_shares WHERE source_msg_id = ?", (source_msg_id,)
).fetchone()
conn.close()
return row is not None
def article_list_new(page: int = 1, per_page: int = 20,
date_from: str = "", date_to: str = "") -> tuple[int, list[dict]]:
"""List articles from articles+article_shares, grouped by URL."""
conn = get_conn()
params = []
where = ""
if date_from:
where += " AND s.shared_at >= ?"
params.append(date_from + " 00:00:00")
if date_to:
where += " AND s.shared_at < ?"
params.append(date_to + " 23:59:59")
total = conn.execute(
f"SELECT COUNT(DISTINCT a.id) FROM articles a JOIN article_shares s ON a.id = s.article_id WHERE 1=1{where}",
params
).fetchone()[0]
conn.row_factory = lambda c, r: dict(zip([col[0] for col in c.description], r))
rows = conn.execute(
f"""SELECT a.id, a.url, a.title,
MAX(s.msg_create_time) as createTime,
COUNT(DISTINCT s.chatroom_id) as shareCount,
GROUP_CONCAT(DISTINCT s.chatroom_id) as groups,
MIN(s.shared_at) as first_seen
FROM articles a
JOIN article_shares s ON a.id = s.article_id
WHERE 1=1{where}
GROUP BY a.id
ORDER BY MAX(s.msg_create_time) DESC
LIMIT ? OFFSET ?""",
params + [per_page, (page - 1) * per_page]
).fetchall()
conn.close()
articles = []
for r in rows:
groups = r["groups"].split(",") if r["groups"] else []
articles.append({
"url": r["url"], "title": r["title"],
"createTime": r["createTime"] or 0,
"shareCount": r["shareCount"], "groups": groups,
})
return total, articles
def _url_dedup_key(url: str) -> str:
"""Extract WeChat article dedup key (biz|mid|idx|sn) or fall back to the full URL."""
if not url or "mp.weixin.qq.com" not in url:
return url or ""
biz = re.search(r"__biz=([^&]+)", url)
mid = re.search(r"mid=(\d+)", url)
idx = re.search(r"idx=(\d+)", url)
sn = re.search(r"sn=([a-f0-9]+)", url)
if biz and mid and idx and sn:
return f"{biz.group(1)}|{mid.group(1)}|{idx.group(1)}|{sn.group(1)}"
return url
def article_get_unsummarized_new(limit: int = 20) -> list[dict]:
"""Articles without summaries (from new tables), newest first.
A summary is considered present if a daily_reports row exists with
report_date='summary' and a matching dedup_key or article_url.
"""
conn = get_conn()
conn.row_factory = lambda c, r: dict(zip([col[0] for col in c.description], r))
# Load all articles with shares and all summary reports once.
articles = conn.execute(
"""SELECT DISTINCT a.id, a.url, a.title, MIN(s.chatroom_id) as chatroom_id,
MAX(s.msg_create_time) as latest_share
FROM articles a
JOIN article_shares s ON a.id = s.article_id
GROUP BY a.id"""
).fetchall()
reports = conn.execute(
"""SELECT dedup_key, article_url FROM daily_reports
WHERE report_date = 'summary'
AND content NOT LIKE '%生成失败%'
AND length(content) > 50"""
).fetchall()
# Build a set of dedup_keys and normalized article_urls for fast lookup.
report_keys = set()
report_urls = set()
for r in reports:
if r["dedup_key"]:
report_keys.add(r["dedup_key"])
if r["article_url"]:
report_urls.add(r["article_url"])
report_urls.add(r["article_url"].replace("&", "&"))
unsummarized = []
for a in articles:
dk = _url_dedup_key(a["url"])
if dk in report_keys or a["url"] in report_urls:
continue
unsummarized.append(dict(a))
unsummarized.sort(key=lambda x: x.get("latest_share") or 0, reverse=True)
conn.close()
return unsummarized[:limit]
def article_count_summarized() -> int:
"""Return number of articles that have a cached summary (report_date='summary').
Matches by dedup_key or normalized article_url, handling historical data where
article_url may contain & or even be stored as a dedup_key.
"""
conn = get_conn()
articles = conn.execute("SELECT id, url FROM articles").fetchall()
reports = conn.execute(
"""SELECT dedup_key, article_url FROM daily_reports
WHERE report_date = 'summary'
AND content NOT LIKE '%生成失败%'
AND length(content) > 50"""
).fetchall()
report_keys = set()
report_urls = set()
for r in reports:
if r["dedup_key"]:
report_keys.add(r["dedup_key"])
if r["article_url"]:
report_urls.add(r["article_url"])
report_urls.add(r["article_url"].replace("&", "&"))
count = 0
for a in articles:
dk = _url_dedup_key(a["url"])
if dk in report_keys or a["url"] in report_urls:
count += 1
conn.close()
return count
def article_count_learned() -> int:
"""Return number of articles that have a cached learn result (report_date='learn')."""
conn = get_conn()
articles = conn.execute("SELECT id, url FROM articles").fetchall()
reports = conn.execute(
"""SELECT dedup_key, article_url FROM daily_reports
WHERE report_date = 'learn' AND article_url IS NOT NULL"""
).fetchall()
report_keys = set()
report_urls = set()
for r in reports:
if r["dedup_key"]:
report_keys.add(r["dedup_key"])
if r["article_url"]:
report_urls.add(r["article_url"])
report_urls.add(r["article_url"].replace("&", "&"))
count = 0
for a in articles:
dk = _url_dedup_key(a["url"])
if dk in report_keys or a["url"] in report_urls:
count += 1
conn.close()
return count
# ── 文章摘要状态机(2026-10-04,需求 reczz28JC9dkrLDn)────────────
# 语义:pending=未排上摘要(应阻塞日报);summarizing=已派发抓取中;
# ok=已摘好;failed=连续失败达上限(不阻塞日报,正文以「缺摘要」可见)。
SUMMARY_STATUS_PENDING = "pending"
SUMMARY_STATUS_SUMMARIZING = "summarizing"
SUMMARY_STATUS_OK = "ok"
SUMMARY_STATUS_FAILED = "failed"
SUMMARY_MAX_ATTEMPTS = 3 # 连续失败达此次数 ⇒ failed
SUMMARY_STALE_MINUTES = 10 # summarizing 悬挂超此分钟数视为可重派
def _article_match_clause(dedup_key: str | None, url: str | None):
"""构造 articles 行匹配条件:dedup_key 或 url 任一命中。返回 (where, params)。"""
conds, params = [], []
if dedup_key:
conds.append("dedup_key = ?")
params.append(dedup_key)
if url:
conds.append("url = ?")
params.append(url)
if not conds:
return None, []
return "(" + " OR ".join(conds) + ")", params
def article_summary_get_state(dedup_key: str | None = None,
url: str | None = None) -> dict | None:
"""读取某篇文章的摘要状态行;找不到返回 None。"""
where, params = _article_match_clause(dedup_key, url)
if not where:
return None
conn = get_conn()
row = conn.execute(
f"""SELECT id, url, dedup_key, summary_status, summary_attempts, summary_updated_at
FROM articles WHERE {where} LIMIT 1""",
params
).fetchone()
conn.close()
return dict(row) if row else None
def article_summary_set_dispatching(dedup_key: str | None = None,
url: str | None = None) -> bool:
"""派发摘要前调用:attempts+1、status='summarizing'、updated_at=now。
- status='failed' 的行不再自增/改状态(防重派,返回 False)。
- summarizing 悬挂(updated_at 超 SUMMARY_STALE_MINUTES)视为可重派(照常 attempts+1)。
返回是否命中并更新了行。
"""
where, params = _article_match_clause(dedup_key, url)
if not where:
return False
conn = get_conn()
cur = conn.execute(
f"""UPDATE articles
SET summary_attempts = COALESCE(summary_attempts, 0) + 1,
summary_status = 'summarizing',
summary_updated_at = datetime('now','localtime')
WHERE {where} AND COALESCE(summary_status, 'pending') != 'failed'""",
params
)
conn.commit()
changed = cur.rowcount > 0
conn.close()
return changed
def article_summary_mark_failed(dedup_key: str | None = None,
url: str | None = None) -> str:
"""记录一次摘要失败:按 attempts 决定 → 达上限转 'failed',否则回落 'pending'。
返回写入后的状态('' 表示未命中行)。
"""
where, params = _article_match_clause(dedup_key, url)
if not where:
return ""
conn = get_conn()
row = conn.execute(
f"SELECT summary_attempts FROM articles WHERE {where} LIMIT 1", params
).fetchone()
attempts = (row["summary_attempts"] if row else 0) or 0
new_status = SUMMARY_STATUS_FAILED if attempts >= SUMMARY_MAX_ATTEMPTS else SUMMARY_STATUS_PENDING
conn.execute(
f"""UPDATE articles SET summary_status = ?,
summary_updated_at = datetime('now','localtime')
WHERE {where}""",
[new_status, *params]
)
conn.commit()
conn.close()
return new_status
def article_summary_mark_ok(dedup_key: str | None = None,
url: str | None = None) -> bool:
"""摘要成功入库后调用:status='ok'。返回是否命中行。"""
where, params = _article_match_clause(dedup_key, url)
if not where:
return False
conn = get_conn()
cur = conn.execute(
f"""UPDATE articles SET summary_status = 'ok',
summary_updated_at = datetime('now','localtime')
WHERE {where}""",
params
)
conn.commit()
changed = cur.rowcount > 0
conn.close()
return changed
def article_summary_state_counts() -> dict:
"""按状态统计 articles:{pending, summarizing, ok, failed, total}。"""
counts = {"pending": 0, "summarizing": 0, "ok": 0, "failed": 0}
conn = get_conn()
rows = conn.execute(
"""SELECT COALESCE(summary_status,'pending') AS s, COUNT(*) AS c
FROM articles GROUP BY COALESCE(summary_status,'pending')"""
).fetchall()
conn.close()
for r in rows:
key = r["s"] if r["s"] in counts else "pending"
counts[key] += r["c"]
counts["total"] = sum(counts[k] for k in ("pending", "summarizing", "ok", "failed"))
return counts
def article_summary_failed_titles(limit: int = 100) -> list[str]:
"""列出 failed 状态的文章标题。"""
conn = get_conn()
rows = conn.execute(
"""SELECT title FROM articles
WHERE summary_status = 'failed'
ORDER BY summary_updated_at DESC LIMIT ?""",
(limit,)
).fetchall()
conn.close()
return [r["title"] for r in rows if r["title"]]
def article_get_by_group(chatroom_id: str, limit: int = 50) -> list[dict]:
"""Get all articles shared in a specific group, newest first."""
conn = get_conn()
conn.row_factory = lambda c, r: dict(zip([col[0] for col in c.description], r))
rows = conn.execute(
"""SELECT DISTINCT a.id, a.url, a.title,
MAX(s.msg_create_time) as createTime,
r.content as summary, r.id as report_id
FROM articles a
JOIN article_shares s ON a.id = s.article_id
LEFT JOIN daily_reports r ON r.article_url = a.url AND r.report_date = 'summary'
WHERE s.chatroom_id = ?
GROUP BY a.id
ORDER BY MAX(s.msg_create_time) DESC
LIMIT ?""",
(chatroom_id, limit)
).fetchall()
conn.close()
return [dict(r) for r in rows]
# ── 图片分析 ────────────────────────────────────────
def image_list(limit: int = 100) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM image_analysis ORDER BY id DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def image_add(source_msg_id: int, chatroom_id: str, talker: str,
image_path: str, ocr_text: str = "", description: str = "") -> int:
conn = get_conn()
cur = conn.execute(
"""INSERT INTO image_analysis (source_msg_id, chatroom_id, talker, image_path, ocr_text, description)
VALUES (?, ?, ?, ?, ?, ?)""",
(source_msg_id, chatroom_id, talker, image_path, ocr_text, description)
)
conn.commit()
row_id = cur.lastrowid
conn.close()
return row_id
# ── 群聊总结 ────────────────────────────────────────
def summary_list(limit: int = 50) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM chat_summaries ORDER BY date DESC, id DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def summary_add(chatroom_id: str, date: str, summary: str, msg_count: int = 0) -> int:
conn = get_conn()
cur = conn.execute(
"""INSERT INTO chat_summaries (chatroom_id, date, summary, msg_count)
VALUES (?, ?, ?, ?)""",
(chatroom_id, date, summary, msg_count)
)
conn.commit()
row_id = cur.lastrowid
conn.close()
return row_id
# ── Hermes 子进程追踪 ───────────────────────────────
def job_add(task_id: str, job_type: str, job_name: str = "", log_file: str = "") -> int:
conn = get_conn()
cur = conn.execute(
"""INSERT INTO hermes_jobs (task_id, job_type, job_name, log_file)
VALUES (?, ?, ?, ?)""",
(task_id, job_type, job_name, log_file)
)
conn.commit()
row_id = cur.lastrowid
conn.close()
return row_id
def job_update_status(task_id: str, status: str):
conn = get_conn()
if status in ("completed", "failed"):
conn.execute(
"UPDATE hermes_jobs SET status = ?, completed_at = datetime('now','localtime') WHERE task_id = ?",
(status, task_id)
)
else:
conn.execute(
"UPDATE hermes_jobs SET status = ? WHERE task_id = ?",
(status, task_id)
)
conn.commit()
conn.close()
def job_list(limit: int = 100) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM hermes_jobs ORDER BY id DESC LIMIT ?", (limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def job_get(task_id: str) -> dict | None:
conn = get_conn()
row = conn.execute(
"SELECT * FROM hermes_jobs WHERE task_id = ?", (task_id,)
).fetchone()
conn.close()
return dict(row) if row else None
def job_delete(task_id: str):
conn = get_conn()
conn.execute("DELETE FROM hermes_jobs WHERE task_id = ?", (task_id,))
conn.commit()
conn.close()
# ── 学习吸收管线 ────────────────────────────────────
def learn_add(article_url: str, title: str, dedup_key: str,
learn_type: str, learn_target: str = "",
learn_content: str = "", confidence: str = "medium",
score: int = 0, status: str = "pending_review",
outline: str = "") -> int:
conn = get_conn()
cur = conn.execute(
"""INSERT INTO learn_queue (article_url, title, dedup_key, learn_type,
learn_target, learn_content, confidence, score, status, outline)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(article_url, title, dedup_key, learn_type,
learn_target, learn_content, confidence, score, status, outline)
)
conn.commit()
row_id = cur.lastrowid
conn.close()
return row_id
def learn_list(status: str = "pending_review", limit: int = 200) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM learn_queue WHERE status = ? ORDER BY id DESC LIMIT ?",
(status, limit)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def learn_get(item_id: int) -> dict | None:
conn = get_conn()
row = conn.execute(
"SELECT * FROM learn_queue WHERE id = ?", (item_id,)
).fetchone()
conn.close()
return dict(row) if row else None
def learn_update_status(item_id: int, status: str, error_msg: str = ""):
conn = get_conn()
if status in ("approved", "rejected", "applied"):
conn.execute(
"""UPDATE learn_queue SET status = ?, reviewed_at = datetime('now','localtime'),
error_msg = ? WHERE id = ?""",
(status, error_msg, item_id)
)
else:
conn.execute(
"UPDATE learn_queue SET status = ?, error_msg = ? WHERE id = ?",
(status, error_msg, item_id)
)
conn.commit()
conn.close()
def learn_find_by_dedup(dedup_key: str, learn_type: str = "") -> dict | None:
"""Check if an article already has a learn queue entry"""
conn = get_conn()
if learn_type:
row = conn.execute(
"SELECT * FROM learn_queue WHERE dedup_key = ? AND learn_type = ? ORDER BY id DESC LIMIT 1",
(dedup_key, learn_type)
).fetchone()
else:
row = conn.execute(
"SELECT * FROM learn_queue WHERE dedup_key = ? ORDER BY id DESC LIMIT 1",
(dedup_key,)
).fetchone()
conn.close()
return dict(row) if row else None
def learn_get_unclassified_summaries(limit: int = 50) -> list[dict]:
"""Find article summaries that haven't been classified yet"""
conn = get_conn()
rows = conn.execute(
"""SELECT dr.* FROM daily_reports dr
WHERE dr.report_date = 'summary'
AND dr.dedup_key IS NOT NULL
AND dr.dedup_key NOT IN (
SELECT DISTINCT lq.dedup_key FROM learn_queue lq
WHERE lq.dedup_key IS NOT NULL
)
ORDER BY dr.id DESC LIMIT ?""",
(limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
# ── 学习结果记录 ────────────────────────────────────
def learn_result_add(learn_queue_id: int, article_url: str = "",
article_title: str = "", outline: str = "",
result_type: str = "", result_summary: str = "",
created_skill: str = "", updated_skill: str = "",
learned_methodology: str = "",
verification_result: str = "",
architecture_change: str = "",
status: str = "applied",
auto_applied: int = 0) -> int:
conn = get_conn()
cur = conn.execute(
"""INSERT INTO learning_results
(learn_queue_id, article_url, article_title, outline,
result_type, result_summary, created_skill, updated_skill,
learned_methodology, verification_result, architecture_change,
status, auto_applied)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(learn_queue_id, article_url, article_title, outline,
result_type, result_summary, created_skill, updated_skill,
learned_methodology, verification_result, architecture_change,
status, auto_applied)
)
conn.commit()
row_id = cur.lastrowid
conn.close()
return row_id
def learn_result_list(limit: int = 100) -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM learning_results ORDER BY id DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def learn_result_get(item_id: int) -> dict | None:
conn = get_conn()
row = conn.execute(
"SELECT * FROM learning_results WHERE id = ?", (item_id,)
).fetchone()
conn.close()
return dict(row) if row else None
def learn_result_get_by_learn_id(learn_queue_id: int) -> dict | None:
conn = get_conn()
row = conn.execute(
"SELECT * FROM learning_results WHERE learn_queue_id = ? ORDER BY id DESC LIMIT 1",
(learn_queue_id,)
).fetchone()
conn.close()
return dict(row) if row else None
def learn_result_get_apply_audit(learn_queue_id: int) -> dict | None:
"""取 Step 6 知识应用审计报告。"""
conn = get_conn()
row = conn.execute(
"SELECT * FROM learning_results WHERE learn_queue_id = ? AND result_type='apply_audit' ORDER BY id DESC LIMIT 1",
(learn_queue_id,)
).fetchone()
conn.close()
return dict(row) if row else None
def learn_result_get_step4(learn_queue_id: int) -> dict | None:
"""取 Step 4 吸收报告(排除 apply_audit)。"""
conn = get_conn()
row = conn.execute(
"SELECT * FROM learning_results WHERE learn_queue_id = ? AND result_type != 'apply_audit' ORDER BY id DESC LIMIT 1",
(learn_queue_id,)
).fetchone()
conn.close()
return dict(row) if row else None
def learn_result_add_feedback(item_id: int, feedback: str):
conn = get_conn()
conn.execute(
"UPDATE learning_results SET human_feedback = ? WHERE id = ?",
(feedback, item_id)
)
conn.commit()
conn.close()
def learn_result_update_status(item_id: int, status: str):
conn = get_conn()
conn.execute(
"UPDATE learning_results SET status = ? WHERE id = ?",
(status, item_id)
)
conn.commit()
conn.close()
def learn_dagger_corrections(limit: int = 50) -> list[dict]:
"""DAgger Correction 模式查询:找出通过 REVISE 重试后成功的吸收项。
RoboTTT 实证 (2026-08-03): 保留失败为上下文的纠正学习 (+36%)
价值是纯成功路径 (+9%) 的 4x。此查询表面这些高价值案例供离线分析。
检测逻辑: error_msg 含 action=REVISE 但 status=applied(说明重试成功了)。
"""
conn = get_conn()
rows = conn.execute(
"""SELECT id, title, learn_type, learn_target, score, error_msg, created_at
FROM learn_queue
WHERE status = 'applied'
AND error_msg LIKE '%action=REVISE%'
ORDER BY id DESC LIMIT ?""",
(limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def learn_dagger_stats() -> dict:
"""DAgger 统计:计算纠正学习 vs 纯成功的比例。
Returns: {total_applied, dagger_count, dagger_ratio, pure_count}
"""
conn = get_conn()
total = conn.execute(
"SELECT COUNT(*) FROM learn_queue WHERE status = 'applied'"
).fetchone()[0]
dagger = conn.execute(
"SELECT COUNT(*) FROM learn_queue WHERE status = 'applied'"
" AND error_msg LIKE '%action=REVISE%'"
).fetchone()[0]
conn.close()
return {
"total_applied": total,
"dagger_count": dagger,
"dagger_ratio": round(dagger / total * 100, 1) if total > 0 else 0,
"pure_count": total - dagger,
}
# ── 分类路由阈值:单一事实来源(校准可推导,禁止散落字面量)──
# 判据(pplx-decider 决策模型知识, learn_queue #bcf45b01, 2026-10-13):
# 阈值只有在置信度/分数**经校准**后才可信——「说 90% 把握时真实准确率也接近 90%」,
# 否则概率只是装饰品,阈值就是拍板常量。实测口径见 learn_calibration_stats()。
# 改阈值只改这里(5 处散落字面量已收敛 ⇒ 校准结论有落点)。
LEARN_ROUTE_LEARN = 7 # score >= 此值 → pending_learn(自动吸收)
LEARN_ROUTE_REVIEW = 4 # score >= 此值 → pending_review(人工审核)
# 校准判据常量(确定性事实,非 LLM 评分)
CALIBRATION_MIN_SAMPLE = 20 # 每档最少已决样本;低于此不出结论(小样本 = 假置信)
CALIBRATION_TARGET_SUCCESS = 0.8 # "high" 档目标成功率下限
def learn_calibration_stats() -> dict:
"""分类器评分校准统计(奖励黑客解毒剂,2026-08-05)。
Hermes 官方设计哲学知识:评价指标本身会被污染——分类器的高分文章
如果被人工标记为「不值得学」,说明评分被污染,需要校准。
核心 KPI:score 准确率 = 高分项最终被吸收(applied)的比例。
2026-10-13 增(pplx-decider 决策模型,learn_queue #bcf45b01):
校准比分数本身更值钱 ⇒ 补三件确定性事实:
① confidence_calibration 经验校准曲线(分档真实成功率)——
旧版只有 confidence_high_failed 计数,无法回答「high 到底可不可信」;
② 分母含 failed(旧 high_precision 只看 applied+rejected,漏最大失败档 ⇒ 假阴);
③ n_decided / reliable 样本量守门(decided=1 也回 100% 是小样本假置信)。
Returns:
- auto_high_score_applied: score>=7 且最终 applied(分类器正确)
- auto_high_score_rejected: score>=7 但人工/系统 rejected(评分被推翻)
- high_precision: auto_high_score_applied / (applied+rejected) 中 score>=7 的成功率
- confidence_calibration: 分档 {n_decided, applied, failed, rejected, success_rate}
- confidence_verdict: CALIBRATED / BELOW_TARGET / MISCALIBRATED / INSUFFICIENT_SAMPLE
- threshold_at: 学习路由阈值处的实测精度 + 样本量 + reliable 标志
- confidence_calibration: confidence=high 但 failed/rejected 的数量(置信度虚高)
- score_distribution: 按 score 分档的最终状态分布
"""
conn = get_conn()
def _q(sql, args=()):
return conn.execute(sql, args).fetchone()[0]
_THR = LEARN_ROUTE_LEARN
high_applied = _q("SELECT COUNT(*) FROM learn_queue WHERE score >= ? AND status = 'applied'", (_THR,))
high_rejected = _q("SELECT COUNT(*) FROM learn_queue WHERE score >= ? AND status = 'rejected'", (_THR,))
high_pending = _q("SELECT COUNT(*) FROM learn_queue WHERE score >= ? AND status IN ('pending_review','pending_learn','approved')", (_THR,))
mid_applied = _q("SELECT COUNT(*) FROM learn_queue WHERE score >= ? AND score < ? AND status = 'applied'", (LEARN_ROUTE_REVIEW, _THR))
mid_rejected = _q("SELECT COUNT(*) FROM learn_queue WHERE score >= ? AND score < ? AND status = 'rejected'", (LEARN_ROUTE_REVIEW, _THR))
conf_high_failed = _q("SELECT COUNT(*) FROM learn_queue WHERE confidence = 'high' AND status IN ('failed','rejected')")
# ── 已决结果计数(分母含 failed:failed 是吸收管线最大的失败档,漏掉即假阴)──
def _decided(where_sql, args=()):
row = conn.execute(
"SELECT SUM(status='applied') a, SUM(status='failed') f, SUM(status='rejected') r "
f"FROM learn_queue WHERE {where_sql}", args
).fetchone()
a, f, r = row["a"] or 0, row["f"] or 0, row["r"] or 0
n = a + f + r
return {
"n_decided": n, "applied": a, "failed": f, "rejected": r,
"success_rate": round(a / n, 3) if n else None,
}
# ── ① 经验校准曲线:confidence 档位 vs 真实成功率 ──
confidence_calibration = {}
for bucket in ("high", "medium", "low"):
entry = _decided("confidence = ?", (bucket,))
entry["reliable"] = entry["n_decided"] >= CALIBRATION_MIN_SAMPLE
confidence_calibration[bucket] = entry
# ── ② 阈值处实测精度(含 failed)──
threshold_at = _decided("score >= ?", (_THR,))
threshold_at["reliable"] = threshold_at["n_decided"] >= CALIBRATION_MIN_SAMPLE
threshold_at["threshold"] = _THR
# ── ③ 确定性 verdict(判据来自 DB 计数,非 LLM 评分)──
hi_c, mid_c = confidence_calibration["high"], confidence_calibration["medium"]
if not hi_c["reliable"] or not mid_c["reliable"]:
confidence_verdict = "INSUFFICIENT_SAMPLE"
elif (hi_c["success_rate"] or 0) < (mid_c["success_rate"] or 0):
# high 档比 medium 档更差 ⇒ 置信度与结果反相关,阈值不可信
confidence_verdict = "MISCALIBRATED"
elif (hi_c["success_rate"] or 0) < CALIBRATION_TARGET_SUCCESS:
confidence_verdict = "BELOW_TARGET"
else:
confidence_verdict = "CALIBRATED"
# 按 score 分档的状态分布
dist = []
for lo, hi in ((9, 10), (_THR, _THR + 1), (LEARN_ROUTE_REVIEW, _THR - 1), (1, LEARN_ROUTE_REVIEW - 1)):
rows = conn.execute(
"SELECT status, COUNT(*) c FROM learn_queue WHERE score >= ? AND score <= ? "
"GROUP BY status ORDER BY c DESC",
(lo, hi)
).fetchall()
dist.append({
"band": f"{lo}-{hi}",
"statuses": {r["status"]: r["c"] for r in rows},
})
conn.close()
decided = high_applied + high_rejected
return {
"high_precision": round(high_applied / decided * 100, 1) if decided > 0 else None,
"high_applied": high_applied,
"high_rejected": high_rejected,
"high_pending": high_pending,
"mid_applied": mid_applied,
"mid_rejected": mid_rejected,
"confidence_high_failed": conf_high_failed,
"confidence_calibration": confidence_calibration,
"confidence_verdict": confidence_verdict,
"threshold_at": threshold_at,
"threshold_learn": LEARN_ROUTE_LEARN,
"threshold_review": LEARN_ROUTE_REVIEW,
"min_sample": CALIBRATION_MIN_SAMPLE,
"target_success": CALIBRATION_TARGET_SUCCESS,
"score_distribution": dist,
"note": "high_precision 越高说明分类器评分越可信;rejected 占比高 = 评分被污染,应下调分类阈值。"
"confidence_verdict=MISCALIBRATED 时阈值不可信(先校准置信度再改阈值),"
"口径见 confidence_calibration(分母含 failed)。",
}
# ── 自动回复白名单 ──────────────────────────────────
def whitelist_list() -> list[dict]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM reply_whitelist ORDER BY kind, name, id DESC"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def whitelist_add(kind: str, wxid: str, name: str = "", role: str = "member") -> int:
conn = get_conn()
cur = conn.execute(
"INSERT OR IGNORE INTO reply_whitelist (kind, wxid, name, role) VALUES (?, ?, ?, ?)",
(kind, wxid, name, role)
)
if not cur.lastrowid: # 已存在则更新名字+kind(不覆盖 role)
conn.execute("UPDATE reply_whitelist SET name=?, kind=? WHERE wxid=?",
(name, kind, wxid))
conn.commit()
rid = cur.lastrowid
conn.close()
return rid
def whitelist_remove(item_id: int):
conn = get_conn()
conn.execute("DELETE FROM reply_whitelist WHERE id = ?", (item_id,))
conn.commit()
conn.close()
def whitelist_roles() -> dict[str, str]:
"""给策略引擎用:所有白名单 wxid → role 的映射。"""
conn = get_conn()
rows = conn.execute("SELECT wxid, role FROM reply_whitelist").fetchall()
conn.close()
return {r["wxid"]: (r["role"] or "member") for r in rows}
def whitelist_set_role(item_id: int, role: str):
"""修改白名单条目的角色:'admin' 或 'member'。"""
conn = get_conn()
conn.execute("UPDATE reply_whitelist SET role=? WHERE id=?", (role, item_id))
conn.commit()
conn.close()
# ── 自动回复队列 ────────────────────────────────────
def reply_find_by_msg_id(msg_id) -> dict | None:
"""按 msg_id 查找已入队条目。
PAP 授权层③ 幂等基础设施:同一 source message 只应产生一条待处理回复。
加固「回滚 orch_state.json 后重放 drain」场景(重放不应产生第二条待审批条目)。
"""
if not msg_id:
return None
conn = get_conn()
row = conn.execute(
"SELECT * FROM reply_queue WHERE msg_id = ? ORDER BY id LIMIT 1", (msg_id,)
).fetchone()
conn.close()
return dict(row) if row else None
def reply_add(msg_id, talker, sender, sender_name, kind, incoming,
draft, action, reason, status="pending") -> int:
# 幂等守卫(PAP Operations 唯一 ID):msg_id>0 的真实消息不重复入队,
# 返回已存在条目 id 而非新建。msg_id=0/None 是「无消息来源」哨兵(手工/测试路径),不去重。
if msg_id:
existing = reply_find_by_msg_id(msg_id)
if existing:
return existing["id"]
conn = get_conn()
cur = conn.execute(
"""INSERT INTO reply_queue
(msg_id, talker, sender, sender_name, kind, incoming, draft, action, reason, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(msg_id, talker, sender, sender_name, kind, incoming, draft, action, reason, status)
)
conn.commit()
rid = cur.lastrowid
conn.close()
return rid
def reply_list(status: str = "", limit: int = 100) -> list[dict]:
conn = get_conn()
if status:
rows = conn.execute(
"SELECT * FROM reply_queue WHERE status = ? ORDER BY id DESC LIMIT ?",
(status, limit)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM reply_queue ORDER BY id DESC LIMIT ?", (limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def reply_get(item_id: int) -> dict | None:
conn = get_conn()
row = conn.execute("SELECT * FROM reply_queue WHERE id = ?", (item_id,)).fetchone()
conn.close()
return dict(row) if row else None
def reply_update(item_id: int, status: str, draft: str | None = None):
conn = get_conn()
if status in ("sent", "auto_sent", "failed"):
if draft is not None:
conn.execute(
"UPDATE reply_queue SET status=?, draft=?, sent_at=datetime('now','localtime') WHERE id=?",
(status, draft, item_id))
else:
conn.execute(
"UPDATE reply_queue SET status=?, sent_at=datetime('now','localtime') WHERE id=?",
(status, item_id))
else:
if draft is not None:
conn.execute("UPDATE reply_queue SET status=?, draft=? WHERE id=?",
(status, draft, item_id))
else:
conn.execute("UPDATE reply_queue SET status=? WHERE id=?", (status, item_id))
conn.commit()
conn.close()
# ── AI 聊天:会话 CRUD ────────────────────────────────
def conv_list() -> list[dict]:
"""列出所有会话,按 updated_at 倒序"""
conn = get_conn()
rows = conn.execute(
"SELECT id, title, created_at, updated_at FROM conversations ORDER BY updated_at DESC"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def conv_create(title: str = "新对话") -> int:
"""新建会话,返回 id"""
conn = get_conn()
cur = conn.execute(
"INSERT INTO conversations (title) VALUES (?)", (title,)
)
conn.commit()
cid = cur.lastrowid
conn.close()
return cid
def conv_delete(conv_id: int):
"""删除会话(messages 通过 ON DELETE CASCADE 自动删除)"""
conn = get_conn()
conn.execute("DELETE FROM conversations WHERE id = ?", (conv_id,))
conn.commit()
conn.close()
def conv_update_title(conv_id: int, title: str):
"""更新会话标题"""
conn = get_conn()
conn.execute(
"UPDATE conversations SET title = ? WHERE id = ?", (title, conv_id)
)
conn.commit()
conn.close()
def conv_update_time(conv_id: int):
"""更新会话 updated_at 为当前时间"""
conn = get_conn()
conn.execute(
"UPDATE conversations SET updated_at = datetime('now','localtime') WHERE id = ?",
(conv_id,)
)
conn.commit()
conn.close()
def conv_get(conv_id: int) -> dict | None:
"""获取单个会话"""
conn = get_conn()
row = conn.execute(
"SELECT id, title, created_at, updated_at FROM conversations WHERE id = ?",
(conv_id,)
).fetchone()
conn.close()
return dict(row) if row else None
# ── AI 聊天:消息 CRUD ────────────────────────────────
def msg_list(conv_id: int) -> list[dict]:
"""获取某会话的所有消息,按 created_at 正序"""
conn = get_conn()
rows = conn.execute(
"SELECT id, conversation_id, role, content, created_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",
(conv_id,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def msg_add(conv_id: int, role: str, content: str) -> int:
"""新增一条消息,返回 id。同时更新会话 updated_at。"""
conn = get_conn()
cur = conn.execute(
"INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)",
(conv_id, role, content)
)
conn.execute(
"UPDATE conversations SET updated_at = datetime('now','localtime') WHERE id = ?",
(conv_id,)
)
conn.commit()
mid = cur.lastrowid
conn.close()
return mid
def msg_count(conv_id: int) -> int:
"""获取某会话的消息数"""
conn = get_conn()
cnt = conn.execute(
"SELECT COUNT(*) FROM messages WHERE conversation_id = ?", (conv_id,)
).fetchone()[0]
conn.close()
return cnt
def msg_get(msg_id: int) -> dict | None:
"""获取单条消息"""
conn = get_conn()
row = conn.execute(
"SELECT id, conversation_id, role, content, created_at FROM messages WHERE id = ?",
(msg_id,)
).fetchone()
conn.close()
return dict(row) if row else None
def msg_update(msg_id: int, content: str):
"""更新消息内容"""
conn = get_conn()
conn.execute(
"UPDATE messages SET content = ? WHERE id = ?",
(content, msg_id)
)
conn.commit()
conn.close()
def msg_delete_since(conv_id: int, since_id: int):
"""删除指定会话中 id > since_id 的所有消息(用于重新生成时清空后续回复)"""
conn = get_conn()
conn.execute(
"DELETE FROM messages WHERE conversation_id = ? AND id > ?",
(conv_id, since_id)
)
conn.commit()
conn.close()