Files
kefu/wechat_rpa/knowledge_review.py
T
2026-09-21 10:34:06 +08:00

244 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Durable cross-page approval of a fixed, authorized set of knowledge drafts."""
from __future__ import annotations
import json
import time
import rbac
from archive_store import json_text, new_id, tenant_scope, utc_now
from knowledge_retriever import KnowledgeRetriever
REVIEW_SCHEMA = """
CREATE TABLE IF NOT EXISTS knowledge_review_job (
id TEXT PRIMARY KEY, created_by INTEGER NOT NULL, account_id INTEGER NOT NULL,
scope_label TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'preview',
total INTEGER NOT NULL DEFAULT 0, processed INTEGER NOT NULL DEFAULT 0,
approved INTEGER NOT NULL DEFAULT 0, skipped INTEGER NOT NULL DEFAULT 0,
preview_json TEXT NOT NULL DEFAULT '{}', expires_at REAL NOT NULL,
error TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_knowledge_review_queue ON knowledge_review_job(status,created_at,id);
CREATE INDEX IF NOT EXISTS idx_knowledge_review_owner ON knowledge_review_job(created_by,created_at DESC);
CREATE TABLE IF NOT EXISTS knowledge_review_target (
job_id TEXT NOT NULL REFERENCES knowledge_review_job(id) ON DELETE CASCADE,
item_id TEXT NOT NULL, tenant_id TEXT NOT NULL, revision INTEGER NOT NULL,
item_updated_at TEXT NOT NULL, preview_reason TEXT NOT NULL DEFAULT '',
result TEXT NOT NULL DEFAULT 'pending', reason TEXT NOT NULL DEFAULT '',
PRIMARY KEY(job_id,item_id)
);
CREATE INDEX IF NOT EXISTS idx_knowledge_review_pending ON knowledge_review_target(job_id,result,item_id);
"""
REASONS = {
'': '符合校验条件', 'missing_conditions': '缺少适用条件,需编辑补充',
'invalid_fields': '知识字段不完整、过长或类型无效', 'expired': '已超过有效期',
'missing_source': '缺少来源记录', 'changed_source': '来源已变化,请重新整理',
'changed_item': '预览后知识版本或状态已变化', 'deleted': '知识已删除',
'unauthorized': '该账号已不在当前授权范围',
}
REVIEW_PERMISSIONS = {'knowledge:read', 'knowledge:review', 'im:content:read'}
def reason_sql(alias='k'):
"""The same eligibility predicate is used by preview and execution."""
k = alias
return f"""CASE
WHEN trim({k}.conditions)='' THEN 'missing_conditions'
WHEN trim({k}.title)='' OR length({k}.title)>160 OR trim({k}.question)=''
OR length({k}.question)>4000 OR trim({k}.answer)='' OR length({k}.answer)>8000
OR length({k}.conditions)>2000 OR length({k}.category)>100
OR {k}.kind NOT IN ('qa','procedure','case') THEN 'invalid_fields'
WHEN {k}.valid_until!='' AND {k}.valid_until<? THEN 'expired'
WHEN NOT EXISTS(SELECT 1 FROM knowledge_source s WHERE s.item_id={k}.id) THEN 'missing_source'
WHEN EXISTS(SELECT 1 FROM knowledge_source s LEFT JOIN archive_message m ON m.id=s.message_id
WHERE s.item_id={k}.id AND (m.id IS NULL OR m.tenant_id!={k}.tenant_id OR m.status!='normal'
OR s.version_no!=(SELECT MAX(version_no) FROM archive_message_version WHERE message_id=s.message_id)))
THEN 'changed_source'
ELSE '' END"""
def reviewer_scope(db, actor):
user = db.execute('SELECT role,active FROM users WHERE id=?', (actor,)).fetchone()
permissions = rbac.permissions_of(db, user['role']) if user and user['active'] else set()
if not REVIEW_PERMISSIONS <= permissions:
raise ValueError('审核权限已变化或账号已停用,请恢复权限后继续')
scoped = db.execute('SELECT desktop_account_id FROM admin_account_scope WHERE admin_user_id=?', (actor,)).fetchall()
if 'desktop-account:all' in permissions or not scoped:
return None # Matches the existing "all authorized accounts" resolver.
return {r[0] for r in db.execute('SELECT d.tenant_id FROM desktop_accounts d JOIN admin_account_scope a '
'ON a.desktop_account_id=d.id WHERE a.admin_user_id=?', (actor,))}
class KnowledgeReviewStore:
def __init__(self, store):
self.store = store
self.database = store.database
@staticmethod
def public(row):
result = dict(row)
result['preview'] = json.loads(result.pop('preview_json'))
result['progress_percent'] = (100 if result['status'] == 'completed' else
min(99, int(result['processed'] * 100 / result['total'])) if result['total'] else 0)
return result
@staticmethod
def owned(db, job_id, actor):
row = db.execute('SELECT * FROM knowledge_review_job WHERE id=? AND created_by=?', (job_id, actor)).fetchone()
if row is None:
raise KeyError('审核任务不存在或不属于当前用户')
return row
def get(self, job_id, actor):
with self.database.connect() as db:
return self.public(self.owned(db, job_id, actor))
def jobs(self, actor):
with self.database.connect() as db:
return [self.public(row) for row in db.execute("SELECT * FROM knowledge_review_job WHERE created_by=? "
"AND status NOT IN ('preview','expired') ORDER BY created_at DESC,id LIMIT 30", (actor,))]
def preview(self, scope, account_id, actor, ip):
scope = tenant_scope(scope)
job_id, now = new_id(), utc_now()
with self.database.connect() as db:
db.execute('BEGIN IMMEDIATE')
authorized = reviewer_scope(db, actor)
# Scope comes from the API resolver, and is rechecked under the writer lock.
if authorized is not None and (scope.everything or not set(scope.tenants) <= authorized):
raise ValueError('账号授权已变化,请刷新后重新选择范围')
label = '全部授权账号' if account_id == -1 else '旧版未归属数据'
if account_id > 0:
account = db.execute('SELECT nickname,zyt_sn FROM desktop_accounts WHERE id=?', (account_id,)).fetchone()
if account is None:
raise ValueError('所选账号不存在')
label = f"{account['nickname'] or account['zyt_sn']}({account['zyt_sn']})"
db.execute("UPDATE knowledge_review_job SET status='expired' WHERE created_by=? AND status='preview'", (actor,))
db.execute('INSERT INTO knowledge_review_job(id,created_by,account_id,scope_label,expires_at,created_at,updated_at) '
'VALUES (?,?,?,?,?,?,?)', (job_id, actor, account_id, label, time.time() + 1800, now, now))
# Snapshot only IDs and versions, never load every draft into application memory.
db.execute(f"""INSERT INTO knowledge_review_target(job_id,item_id,tenant_id,revision,item_updated_at,preview_reason)
SELECT ?,k.id,k.tenant_id,k.revision,k.updated_at,{reason_sql()}
FROM knowledge_item k WHERE k.status='draft' AND {scope.clause('k')}""",
[job_id, KnowledgeRetriever._today(), *scope.params])
reasons = dict(db.execute('SELECT preview_reason,COUNT(*) FROM knowledge_review_target WHERE job_id=? '
'GROUP BY preview_reason', (job_id,)))
accounts = [dict(row) for row in db.execute("""SELECT t.tenant_id,COUNT(*) total,
SUM(t.preview_reason='') eligible,COALESCE((SELECT CASE WHEN d.nickname='' THEN d.zyt_sn
ELSE d.nickname||'('||d.zyt_sn||')' END FROM desktop_accounts d WHERE d.tenant_id=t.tenant_id LIMIT 1),
'旧版未归属数据') name FROM knowledge_review_target t WHERE t.job_id=? GROUP BY t.tenant_id""", (job_id,))]
total = sum(reasons.values())
preview = {'total': total, 'eligible': reasons.get('', 0), 'blocked': total - reasons.get('', 0),
'reasons': [{'code': key, 'reason': REASONS[key], 'count': count} for key, count in reasons.items() if key],
'accounts': accounts, 'snapshot_at': now}
db.execute('UPDATE knowledge_review_job SET total=?,preview_json=? WHERE id=?', (total, json_text(preview), job_id))
self.database._audit(db, actor, 'knowledge.review.preview', f'job={job_id} total={total}', ip)
return self.get(job_id, actor)
def action(self, job_id, action, actor, ip, confirmed=False):
if action not in {'start', 'pause', 'resume', 'cancel'}:
raise ValueError('未知审核任务操作')
with self.database.connect() as db:
db.execute('BEGIN IMMEDIATE')
reviewer_scope(db, actor)
row = self.owned(db, job_id, actor)
if action == 'start':
if not confirmed:
raise ValueError('请确认当前范围内的知识已完成必要审核,再批量通过')
# A repeated HTTP submission cannot create or enqueue a second job.
if row['status'] in {'queued', 'running', 'completed'}:
return self.public(row)
if row['status'] != 'preview' or row['expires_at'] < time.time():
raise ValueError('预览已失效,请重新预览审核范围')
if row['total'] == 0:
raise ValueError('当前范围没有待审核草稿')
status = 'queued'
else:
allowed = {'pause': {'queued', 'running'}, 'resume': {'paused', 'failed'},
'cancel': {'preview', 'queued', 'running', 'paused', 'failed'}}[action]
if row['status'] not in allowed:
raise ValueError('当前状态不能执行此操作')
status = {'pause': 'paused', 'resume': 'queued', 'cancel': 'cancelled'}[action]
db.execute("UPDATE knowledge_review_job SET status=?,error='',updated_at=? WHERE id=?", (status, utc_now(), job_id))
self.database._audit(db, actor, 'knowledge.review.' + action, f'job={job_id}', ip)
return self.get(job_id, actor)
def results(self, job_id, actor, scope, result='skipped', page=1, page_size=30):
scope = tenant_scope(scope)
if result not in {'', 'pending', 'approved', 'skipped'}:
raise ValueError('未知审核结果筛选')
where, args = f't.job_id=? AND {scope.clause("t")}', [job_id, *scope.params]
if result:
where += ' AND t.result=?'; args.append(result)
with self.database.connect() as db:
self.owned(db, job_id, actor)
total = db.execute(f'SELECT COUNT(*) FROM knowledge_review_target t WHERE {where}', args).fetchone()[0]
rows = db.execute(f"""SELECT t.item_id,t.tenant_id,t.revision,t.result,t.reason,t.preview_reason,
COALESCE(k.title,'知识已删除') title,
COALESCE((SELECT id FROM desktop_accounts WHERE tenant_id=t.tenant_id LIMIT 1),0) account_id,
COALESCE((SELECT CASE WHEN nickname='' THEN zyt_sn ELSE nickname||'('||zyt_sn||')' END
FROM desktop_accounts WHERE tenant_id=t.tenant_id LIMIT 1),'旧版未归属数据') account_name FROM knowledge_review_target t LEFT JOIN knowledge_item k
ON k.id=t.item_id AND k.tenant_id=t.tenant_id WHERE {where} ORDER BY t.item_id LIMIT ? OFFSET ?""",
[*args, page_size, (page - 1) * page_size]).fetchall()
return {'items': [{**dict(row), 'reason_text': REASONS.get(row['reason'] or row['preview_reason'], '')} for row in rows],
'total': total}
class KnowledgeReviewWorker:
"""Short SQL-only transactions; crash/pause cannot half-commit an approval batch."""
def __init__(self, store):
self.store = store
self.database = store.database
def step(self, batch_size=100):
batch_size = max(1, min(100, batch_size))
with self.database.connect() as db:
db.execute('BEGIN IMMEDIATE')
job = db.execute("SELECT * FROM knowledge_review_job WHERE status IN ('queued','running') "
'ORDER BY created_at,id LIMIT 1').fetchone()
if job is None:
return self.cleanup(db)
try:
authorized = reviewer_scope(db, job['created_by'])
except ValueError as error:
db.execute("UPDATE knowledge_review_job SET status='failed',error=?,updated_at=? WHERE id=?",
(str(error), utc_now(), job['id']))
return True
rows = db.execute(f"""SELECT t.*,k.id current_id,k.revision current_revision,k.status current_status,
k.updated_at current_updated_at,{reason_sql()} eligibility FROM knowledge_review_target t
LEFT JOIN knowledge_item k ON k.id=t.item_id AND k.tenant_id=t.tenant_id
WHERE t.job_id=? AND t.result='pending' ORDER BY t.item_id LIMIT ?""",
(KnowledgeRetriever._today(), job['id'], batch_size)).fetchall()
approved = 0
for row in rows:
reason = ('unauthorized' if authorized is not None and row['tenant_id'] not in authorized else
'deleted' if row['current_id'] is None else
'changed_item' if row['current_revision'] != row['revision'] or row['current_status'] != 'draft'
or row['current_updated_at'] != row['item_updated_at'] else row['eligibility'])
if not reason:
db.execute("UPDATE knowledge_item SET status='approved',updated_at=? WHERE id=?", (utc_now(), row['item_id']))
db.execute('DELETE FROM knowledge_fts WHERE item_id=?', (row['item_id'],))
self.store._record(db, row['item_id'], job['created_by'], 'approve')
approved += 1
db.execute('UPDATE knowledge_review_target SET result=?,reason=? WHERE job_id=? AND item_id=?',
('skipped' if reason else 'approved', reason, job['id'], row['item_id']))
done = job['processed'] + len(rows) >= job['total']
db.execute('UPDATE knowledge_review_job SET status=?,processed=processed+?,approved=approved+?,skipped=skipped+?,updated_at=? WHERE id=?',
('completed' if done else 'running', len(rows), approved, len(rows) - approved, utc_now(), job['id']))
if done:
self.database._audit(db, job['created_by'], 'knowledge.review.complete', f"job={job['id']} approved={job['approved'] + approved}", '')
return True
@staticmethod
def cleanup(db):
# Abandoned previews expire. Delete a bounded number of IDs per idle tick.
row = db.execute("SELECT id FROM knowledge_review_job WHERE (status='preview' AND expires_at<?) OR status='expired' "
'ORDER BY created_at LIMIT 1', (time.time(),)).fetchone()
if row is None:
return False
db.execute("UPDATE knowledge_review_job SET status='expired' WHERE id=?", (row['id'],))
db.execute('DELETE FROM knowledge_review_target WHERE rowid IN (SELECT rowid FROM knowledge_review_target WHERE job_id=? LIMIT 1000)', (row['id'],))
if not db.execute('SELECT 1 FROM knowledge_review_target WHERE job_id=? LIMIT 1', (row['id'],)).fetchone():
db.execute('DELETE FROM knowledge_review_job WHERE id=?', (row['id'],))
return True