+154
-30
@@ -116,6 +116,30 @@ class RouterInvocationUnavailable(RuntimeError):
|
||||
"""A transient route invocation failure that must be retried by the queue."""
|
||||
|
||||
|
||||
def load_conversation_shadow_support() -> tuple[object | None, Callable[..., Any] | None]:
|
||||
if not os.environ.get("JWEBOT_CONVERSATION_SHADOW_CONFIG", "").strip():
|
||||
return None, None
|
||||
root = Path(__file__).resolve().parent
|
||||
source_roots = [
|
||||
root / "packages" / package / "src"
|
||||
for package in (
|
||||
"jwebot-config",
|
||||
"jwebot-sdk",
|
||||
"jwebot-official-extensions",
|
||||
"jwebot-kernel",
|
||||
)
|
||||
]
|
||||
for source_root in reversed(source_roots):
|
||||
if source_root.is_dir():
|
||||
sys.path.insert(0, str(source_root))
|
||||
from jwebot_kernel.conversation_scope import ( # noqa: PLC0415
|
||||
ShadowConversationRegistry,
|
||||
build_shadow_event,
|
||||
)
|
||||
|
||||
return ShadowConversationRegistry.from_environment(), build_shadow_event
|
||||
|
||||
|
||||
def router_error_message(
|
||||
*,
|
||||
result: subprocess.CompletedProcess[str] | None = None,
|
||||
@@ -1577,27 +1601,81 @@ class Recorder:
|
||||
self.state["last_visual_hash"] = ""
|
||||
self.state["visible_signatures"] = []
|
||||
self.state["recent_trigger_signatures"] = []
|
||||
raw_conversations = self.state.get("conversation_states")
|
||||
if not isinstance(raw_conversations, dict):
|
||||
raw_conversations = {}
|
||||
self.state["conversation_states"] = {
|
||||
str(fingerprint): value
|
||||
for fingerprint, value in raw_conversations.items()
|
||||
if isinstance(value, dict)
|
||||
and value.get("message_signature_scheme") == MESSAGE_SIGNATURE_SCHEME
|
||||
}
|
||||
self.pending_hash = ""
|
||||
self.pending_count = 0
|
||||
self.pending_frames: dict[str, tuple[str, int]] = {}
|
||||
self.no_overlap_recovery_ready = False
|
||||
self.recovery_ready_conversations: set[str] = set()
|
||||
(
|
||||
self.shadow_conversations,
|
||||
self.build_shadow_event,
|
||||
) = load_conversation_shadow_support()
|
||||
|
||||
def save_state(self) -> None:
|
||||
write_json(RECORDER_STATE, self.state)
|
||||
|
||||
def conversation_state(self, fingerprint: str) -> dict[str, Any]:
|
||||
conversations = self.state["conversation_states"]
|
||||
value = conversations.get(fingerprint)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if (
|
||||
fingerprint == self.state.get("last_group_fingerprint")
|
||||
and self.state.get("message_signature_scheme")
|
||||
== MESSAGE_SIGNATURE_SCHEME
|
||||
):
|
||||
value = {
|
||||
"initialized": bool(self.state.get("initialized")),
|
||||
"last_layout_hash": str(self.state.get("last_layout_hash", "")),
|
||||
"last_visual_hash": str(self.state.get("last_visual_hash", "")),
|
||||
"visible_signatures": list(
|
||||
self.state.get("visible_signatures", [])
|
||||
),
|
||||
"recent_trigger_signatures": list(
|
||||
self.state.get("recent_trigger_signatures", [])
|
||||
),
|
||||
"message_signature_scheme": MESSAGE_SIGNATURE_SCHEME,
|
||||
}
|
||||
else:
|
||||
value = {
|
||||
"initialized": False,
|
||||
"last_layout_hash": "",
|
||||
"last_visual_hash": "",
|
||||
"visible_signatures": [],
|
||||
"recent_trigger_signatures": [],
|
||||
"message_signature_scheme": MESSAGE_SIGNATURE_SCHEME,
|
||||
}
|
||||
conversations[fingerprint] = value
|
||||
return value
|
||||
|
||||
def capture_cycle(self, *, stable_frames: int) -> None:
|
||||
started = time.monotonic()
|
||||
image = capture_image()
|
||||
crop = message_image(image)
|
||||
layout_hash, visual_hash = incoming_frame_hashes(crop)
|
||||
if visual_hash != self.pending_hash:
|
||||
self.pending_hash = visual_hash
|
||||
self.pending_count = 1
|
||||
group = title_fingerprint(image)
|
||||
conversation = self.conversation_state(group)
|
||||
pending_hash, pending_count = self.pending_frames.get(group, ("", 0))
|
||||
if visual_hash != pending_hash:
|
||||
pending_count = 1
|
||||
else:
|
||||
self.pending_count += 1
|
||||
pending_count += 1
|
||||
self.pending_frames[group] = (visual_hash, pending_count)
|
||||
self.pending_hash = visual_hash
|
||||
self.pending_count = pending_count
|
||||
outcome = "pending"
|
||||
if (
|
||||
self.pending_count >= stable_frames
|
||||
and visual_hash != self.state.get("last_visual_hash")
|
||||
pending_count >= stable_frames
|
||||
and visual_hash != conversation.get("last_visual_hash")
|
||||
):
|
||||
outcome = "process"
|
||||
self.process_stable_image(
|
||||
@@ -1605,12 +1683,13 @@ class Recorder:
|
||||
layout_hash,
|
||||
visual_hash=visual_hash,
|
||||
observed_at=now_iso(),
|
||||
group_fingerprint=group,
|
||||
)
|
||||
telemetry_event(
|
||||
"ocr.capture",
|
||||
elapsed_ms=(time.monotonic() - started) * 1000,
|
||||
outcome=outcome,
|
||||
count=self.pending_count,
|
||||
count=pending_count,
|
||||
source="screen",
|
||||
)
|
||||
|
||||
@@ -1634,60 +1713,89 @@ class Recorder:
|
||||
*,
|
||||
visual_hash: str | None = None,
|
||||
observed_at: str | None = None,
|
||||
group_fingerprint: str | None = None,
|
||||
) -> None:
|
||||
pipeline_started = time.monotonic()
|
||||
crop = message_image(image)
|
||||
_, bubbles = incoming_mask_and_bubbles(crop)
|
||||
messages, elapsed = self.engine.recognize_messages(crop, bubbles)
|
||||
group = group_fingerprint or title_fingerprint(image)
|
||||
conversation = self.conversation_state(group)
|
||||
shadow_target = (
|
||||
self.shadow_conversations.target_for_fingerprint(group)
|
||||
if self.shadow_conversations is not None
|
||||
else None
|
||||
)
|
||||
previous = [
|
||||
value
|
||||
for value in self.state.get("visible_signatures", [])
|
||||
for value in conversation.get("visible_signatures", [])
|
||||
if isinstance(value, str)
|
||||
]
|
||||
recent_trigger_signatures = [
|
||||
value
|
||||
for value in self.state.get("recent_trigger_signatures", [])
|
||||
for value in conversation.get("recent_trigger_signatures", [])
|
||||
if isinstance(value, str)
|
||||
]
|
||||
new_messages, current_signatures, overlap, resynced = new_sequence_suffix(
|
||||
previous,
|
||||
messages,
|
||||
recent_trigger_signatures=recent_trigger_signatures,
|
||||
recover_unseen_trigger=self.no_overlap_recovery_ready,
|
||||
recover_unseen_trigger=group in self.recovery_ready_conversations,
|
||||
)
|
||||
initialized = bool(self.state.get("initialized"))
|
||||
initialized = bool(conversation.get("initialized"))
|
||||
observed_at = observed_at or now_iso()
|
||||
if not initialized:
|
||||
new_messages = []
|
||||
resynced = True
|
||||
self.state["initialized"] = True
|
||||
group = title_fingerprint(image)
|
||||
conversation["initialized"] = True
|
||||
current_trigger_signatures = [
|
||||
message.signature
|
||||
for message in messages
|
||||
if trigger_question(message.text) is not None
|
||||
if (
|
||||
shadow_target.question(message.text, bot_mention=BOT_MENTION)
|
||||
if shadow_target is not None
|
||||
else trigger_question(message.text)
|
||||
)
|
||||
]
|
||||
recent_trigger_signatures = (
|
||||
recent_trigger_signatures + current_trigger_signatures
|
||||
)[-MAX_RECENT_TRIGGER_SIGNATURES:]
|
||||
self.recovery_ready_conversations.add(group)
|
||||
self.no_overlap_recovery_ready = True
|
||||
appended = 0
|
||||
queued = 0
|
||||
shadowed = 0
|
||||
for ordinal, message in enumerate(new_messages):
|
||||
event = history_event(
|
||||
message,
|
||||
observed_at=observed_at,
|
||||
group_fingerprint=group,
|
||||
ordinal=ordinal,
|
||||
)
|
||||
if (
|
||||
self.queue_enabled
|
||||
and event["mention_bot"]
|
||||
and message.sender != "unknown"
|
||||
and message.sender_confidence >= MIN_SENDER_CONFIDENCE
|
||||
):
|
||||
queue_event(event)
|
||||
queued += 1
|
||||
if shadow_target is not None:
|
||||
if self.build_shadow_event is None:
|
||||
raise RuntimeError("shadow event builder is unavailable")
|
||||
event = self.build_shadow_event(
|
||||
shadow_target,
|
||||
observed_at=observed_at,
|
||||
text=message.text,
|
||||
body_confidence=message.body_confidence,
|
||||
visual_signature=message.signature,
|
||||
bot_mention=BOT_MENTION,
|
||||
observed_sender=message.sender,
|
||||
sender_confidence=message.sender_confidence,
|
||||
ordinal=ordinal,
|
||||
)
|
||||
shadowed += 1
|
||||
else:
|
||||
event = history_event(
|
||||
message,
|
||||
observed_at=observed_at,
|
||||
group_fingerprint=group,
|
||||
ordinal=ordinal,
|
||||
)
|
||||
if (
|
||||
self.queue_enabled
|
||||
and event["mention_bot"]
|
||||
and message.sender != "unknown"
|
||||
and message.sender_confidence >= MIN_SENDER_CONFIDENCE
|
||||
):
|
||||
queue_event(event)
|
||||
queued += 1
|
||||
write_history_event(event)
|
||||
appended += 1
|
||||
logging.info(
|
||||
@@ -1698,9 +1806,23 @@ class Recorder:
|
||||
message.confidence,
|
||||
bool(queued),
|
||||
)
|
||||
conversation.update(
|
||||
{
|
||||
"last_capture_at": observed_at,
|
||||
"last_layout_hash": layout_hash,
|
||||
"last_visual_hash": visual_hash or layout_hash,
|
||||
"last_ocr_ms": round(elapsed * 1000),
|
||||
"visible_signatures": current_signatures,
|
||||
"recent_trigger_signatures": recent_trigger_signatures,
|
||||
"message_signature_scheme": MESSAGE_SIGNATURE_SCHEME,
|
||||
"visible_count": len(messages),
|
||||
"last_overlap": overlap,
|
||||
"last_resynced": resynced,
|
||||
}
|
||||
)
|
||||
self.state.update(
|
||||
{
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"last_capture_at": observed_at,
|
||||
"last_layout_hash": layout_hash,
|
||||
"last_visual_hash": visual_hash or layout_hash,
|
||||
@@ -1714,6 +1836,8 @@ class Recorder:
|
||||
"last_resynced": resynced,
|
||||
"events_total": int(self.state.get("events_total", 0)) + appended,
|
||||
"queued_total": int(self.state.get("queued_total", 0)) + queued,
|
||||
"shadowed_total": int(self.state.get("shadowed_total", 0))
|
||||
+ shadowed,
|
||||
"resync_total": int(self.state.get("resync_total", 0))
|
||||
+ int(resynced and initialized),
|
||||
"errors_total": int(self.state.get("errors_total", 0)),
|
||||
@@ -1747,7 +1871,7 @@ class Recorder:
|
||||
group_id=group,
|
||||
count=appended,
|
||||
depth=queued,
|
||||
outcome="queued" if queued else "observed",
|
||||
outcome="shadowed" if shadowed else "queued" if queued else "observed",
|
||||
source="rapidocr",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user