244 lines
15 KiB
Python
244 lines
15 KiB
Python
"""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
|