Files
kefu/deploy/review-assistant-20260917/visual_methods.txt
T
2026-09-21 10:34:06 +08:00

234 lines
12 KiB
Plaintext
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.
def _review_assistant_settings(self):
loader = getattr(self, "_review_assistant_settings_loader", None)
if loader is not None:
return loader()
try:
with open(os.path.join(_SCRIPT_DIR, "app_settings.json"), encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return {}
def _review_assistant_warn(self, detail):
if detail != getattr(self, "_review_assistant_warning", None):
self._review_assistant_warning = detail
print(" [助理审核] " + detail + ";保留人工审核,不猜测人工回复。")
def _get_review_assistant(self, *, verify=False):
"""Bind only to the live window's verified login, never a browser account."""
try:
settings = self._review_assistant_settings()
configured = settings.get("review_assistant_accounts") if isinstance(settings, dict) else None
if not isinstance(configured, dict) or not any(
isinstance(value, dict) and value.get("enabled") for value in configured.values()):
self.close_review_assistant()
return None
current = getattr(self, "_review_assistant", None)
if current is not None and not verify:
return current if current.enabled() else None
from wecom_native_sender import NativeClient
_, pid = win32process.GetWindowThreadProcessId(self.hwnd)
with NativeClient(pid) as client:
account = str(client.account)
identity = (account, int(pid))
if identity != getattr(self, "_review_assistant_identity", None):
self.close_review_assistant()
from review_assistant import ReviewAssistantCoordinator
current = ReviewAssistantCoordinator(
root=_SCRIPT_DIR, settings_loader=self._review_assistant_settings,
account=account, pid=pid, db=getattr(self, "_db_source", None),
log=lambda message: print(" [助理审核] " + str(message)),
)
self._review_assistant = current
self._review_assistant_identity = identity
self._review_assistant_warning = None
return current if current is not None and current.enabled() else None
except Exception as exc:
self.close_review_assistant()
self._review_assistant_warn("当前账号或本机协议不可核验(" + type(exc).__name__ + ")")
return None
def close_review_assistant(self):
assistant = getattr(self, "_review_assistant", None)
self._review_assistant = None
self._review_assistant_identity = None
if assistant is not None:
try:
assistant.close()
except Exception:
pass
@staticmethod
def _review_assistant_protected(state):
return bool(state.get("send_state") or state.get("send_dispatched_at") or
str(state.get("stage") or "") in {
"sending", "receipt_check", "uncertain", "unknown", "sent_uncommitted", "failed"})
def _review_assistant_context(self, state, *, fp=None):
source = getattr(self, "_db_source", None)
identity = getattr(self, "_review_assistant_identity", None)
if source is None or not identity:
return None
account = str(identity[0])
event = state.get("database_event") or {}
bound_account = str(state.get("account") or event.get("account") or "")
conv_id = str(state.get("conv_id") or event.get("conv_id") or "")
if bound_account and bound_account != account:
return None
if conv_id:
context = source.get_conversation_context_by_id(account, conv_id, limit=120)
else:
# This API rejects duplicate names even when an account is supplied.
name = str(state.get("display_name") or "")
context = source.get_conversation_context(name, account=account, limit=120) if name else None
if (not isinstance(context, dict) or str(context.get("account") or "") != account
or not context.get("conv_id") or (conv_id and context.get("conv_id") != conv_id)):
return None
if fp is not None:
state["account"] = account
state["conv_id"] = str(context["conv_id"])
return context
def _review_assistant_recipient(self, state):
assistant = self._get_review_assistant()
if assistant is None:
return False
try:
context = self._review_assistant_context(state)
return bool(context and assistant.is_recipient(context["conv_id"]))
except Exception:
return False
def _review_assistant_resumed(self, state):
assistant = self._get_review_assistant(verify=True)
if assistant is None or not isinstance(state, dict):
return False
try:
context = self._review_assistant_context(state)
return bool(context and assistant.resumed(context["conv_id"]))
except Exception:
return False
def _register_assistant_review(self, fp):
state = self._pending_reply_state(fp)
if not state or not state.get("awaiting_review") or self._review_assistant_protected(state):
return
assistant = self._get_review_assistant(verify=True)
if assistant is None:
return
try:
context = self._review_assistant_context(state, fp=fp)
if not context:
self._review_assistant_warn("无法按当前账号核对待审核会话")
return
if assistant.is_recipient(context["conv_id"]):
return
# Bind the task identity durably before a reminder may be queued.
if not self._persist_pending_replies():
return
review_id = assistant.ensure_review(state, context)
if review_id:
state["review_assistant_id"] = str(review_id)
self._persist_pending_replies()
assistant.enqueue_notifications()
except Exception as exc:
self._review_assistant_warn("审核提醒登记暂不可用(" + type(exc).__name__ + ")")
def poll_review_assistant(self):
"""Observe phone replies without moving the window or sending the old draft."""
now = time.monotonic()
if now < getattr(self, "_review_assistant_poll_at", 0.0):
return
self._review_assistant_poll_at = now + 2.0
stop = getattr(self, "_stop_check", None)
if stop is not None and stop.is_set():
return
assistant = self._get_review_assistant(verify=True)
if assistant is None:
return
from contextlib import nullcontext
for key, state in list(getattr(self, "_pending_reply_sessions", {}).items()):
if not state.get("awaiting_review") or self._review_assistant_protected(state):
continue
try:
fp = bytes.fromhex(key)
context = self._review_assistant_context(state, fp=fp)
if not context or assistant.is_recipient(context["conv_id"]):
continue
review_id = assistant.ensure_review(state, context)
if not review_id:
continue
state["review_assistant_id"] = str(review_id)
evidence = assistant.reconcile(state, context)
if not evidence:
continue
with getattr(self, "_pending_lock", None) or nullcontext():
if (self._pending_reply_state(fp) is not state or not state.get("awaiting_review")
or self._review_assistant_protected(state)):
continue
discarded = getattr(self, "_review_assistant_discarded_drafts", None)
if discarded is None:
discarded = self._review_assistant_discarded_drafts = {}
draft_key = str(context["account"]) + "\0" + str(context["conv_id"])
previous = discarded.get(draft_key)
discarded[draft_key] = {"account": context["account"], "conv_id": context["conv_id"],
"draft": str(state.get("reply_text") or state.get("staged_reply_text") or ""),
"review_id": str(review_id), "completed_at": time.time()}
# Remove only this exact old task; a concurrent replacement must survive.
self._pending_reply_sessions.pop(key)
if not self._persist_pending_replies():
self._pending_reply_sessions[key] = state
if previous is None:
discarded.pop(draft_key, None)
else:
discarded[draft_key] = previous
continue
getattr(self, "_pending_scan_progress", {}).pop(key, None)
getattr(self, "_pending_exchanges", {}).pop(key, None)
self.queue_log.append(self.session_id_of(fp), str(state.get("display_name") or "客户"),
"已审核", "已核对同账号人工回复,旧审核草稿已作废;后续新消息重新经过风控")
# Preserve customer messages arriving after the proven human reply.
last = context.get("last_message") or {}
if last and not last.get("is_self") and last.get("dedup_key"):
self.enqueue_external_reply(key, display_name=str(context.get("display_name") or ""),
chat_text=str(context.get("text") or ""), dedup_key=str(last["dedup_key"]),
detected_by="人工审核续接", database_event={
"account": context["account"], "conv_id": context["conv_id"],
"message_id": last["dedup_key"], "send_time": last.get("send_time"),
"name_unique": False})
except Exception as exc:
self._review_assistant_warn("人工回复核验暂不可用(" + type(exc).__name__ + ")")
assistant.enqueue_notifications()
def _is_discarded_review_draft(self, state, draft):
"""Called only after normal composer identity and focus checks have passed."""
if not draft or self._review_assistant_protected(state or {}):
return False
try:
if self._get_review_assistant(verify=True) is None:
return False
context = self._review_assistant_context(state or {})
if not context:
return False
key = str(context["account"]) + "\0" + str(context["conv_id"])
entry = getattr(self, "_review_assistant_discarded_drafts", {}).get(key) or {}
# Strict raw comparison: edited human text is never eligible for deletion.
return bool(entry.get("draft") and str(draft).replace("\r\n", "\n") ==
str(entry["draft"]).replace("\r\n", "\n"))
except Exception:
return False
def _record_assistant_automated_send(self, state):
assistant = self._get_review_assistant()
if assistant is None or not isinstance(state, dict):
return
try:
context = self._review_assistant_context(state)
if context:
assistant.record_automated_send({**state, "account": context["account"],
"conv_id": context["conv_id"]}, {"status": "confirmed", "transport": "visual",
"confirmedAt": time.time(), "text": str(state.get("reply_text") or state.get("staged_reply_text") or ""),
"messages": context.get("messages") or []})
except Exception as exc:
self._review_assistant_warn("自动回复留痕暂不可用(" + type(exc).__name__ + ")")