"""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 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