mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
Some checks failed
Tests ReMe / Unit Tests - py3.12 (push) Has been cancelled
Tests ReMe / Unit Tests - py3.13 (push) Has been cancelled
Windows Smoke / CLI smoke - py3.11 (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Tests ReMe / Unit Tests - py3.11 (push) Has been cancelled
* refactor: rebuild auto-fin and daily-paper cookbooks on structured-output agents Rework the auto-fin and daily-paper cookbooks to run on structured-output LLM agents instead of Claude Code agent wrappers, replace the SSH proxy with data-source mirrors, and rewrite the affected unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(auto_fin): unify JSON output serialization and writing - Extracted _write_output static method to serialize and write Pydantic models as compact JSON - Replaced inline JSON dump and write calls with _write_output usage across auto_fin steps - Added _report_path and _current_report for managing intra-day reports in AutoFinMergeStep - Updated auto_fin merge step to write output via new _write_output method - Enhanced news reading with caching in AutoFinHistoryStep - Refined returns calculation to handle events before close on non-trading days correctly feat(daily_paper): improve note path resolution and metadata handling - Introduced iter_note_metadata generator for safe Markdown frontmatter iteration - Added resolve_unique_note_path to avoid note filename conflicts on disk and in used titles - Updated analyze, collect, digest, and select steps to use centralized constants and helpers - Used utc_now_iso for consistent timestamping in metadata - Replaced direct frontmatter loads with iter_note_metadata in collect and analyze steps - Replaced hardcoded paper selection count with PAPER_COUNT constant in all relevant places - Added _MAX_SELECT_ATTEMPTS constant in select step for attempt management - Improved error messages for filename validation in daily paper title normalization feat(auto_fin): add multi-run cron schedules for intraday refinement - Defined three auto_fin cron jobs at 09:30, 11:30, and 18:00 Shanghai time for gradual report updates - Each intraday run adds evidence cumulatively instead of replacing prior output wholly - Updated daily_cookbook.yaml to register new cron schedules and remove legacy 12:00 cron refactor(auto_fin_data): clean ETF code handling and page limits - Replaced hardcoded DEFAULT_ETF_CODES with required non-empty config value "etf_codes" - Added constants for major news and fund page limits to control pagination - Improved ETF name extraction logic to handle missing fields consistently fix(auto_fin_merge): fix report retrieval and merging logic - Added support for getting current intra-day report in addition to previous day's report - Modified merge template to include prior and current report sections for better context - Adjusted report path handling to consistently use Path objects test(auto_fin): add coverage for returns calculation and report retrieval - Added test for returns when event occurs before close on non-trading day, checking next session entry - Added test for previous and current report retrieval feeding merge context with disk files - Extended test asserts for auto_fin cron schedule changes in config style(daily_paper): reorder and cleanup imports - Reorganized imports in _common.py for clarity and added missing collections.abc.Iterator import - Cleaned up commented and unused imports across daily_paper steps * feat: add configurable upstream mirror proxy * style: format auto-fin data step * fix: align cookbook mirrors and contracts --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
11 KiB
Python
240 lines
11 KiB
Python
"""Long-running DingTalk Stream bridge for the cookbook application."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from typing import Any
|
|
from urllib.parse import quote_plus
|
|
|
|
from ...base_step import BaseStep
|
|
from ....components import R
|
|
from ....components.agent_wrapper import handle_session_command
|
|
|
|
|
|
def _session_key(message: Any) -> str:
|
|
"""Return the per-sender, per-conversation Claude session key."""
|
|
parts = (
|
|
message.conversation_type,
|
|
message.conversation_id,
|
|
message.sender_staff_id,
|
|
)
|
|
if not all(parts):
|
|
raise ValueError("DingTalk message requires conversationType, conversationId, and senderStaffId")
|
|
return ":".join(parts)
|
|
|
|
|
|
def _session_ref(key: str) -> str:
|
|
"""Return a stable log correlation id without exposing DingTalk identifiers."""
|
|
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:12]
|
|
|
|
|
|
@R.register("dingtalk_wait_step")
|
|
class DingTalkWaitStep(BaseStep):
|
|
"""Receive DingTalk messages and send final Agent responses as Markdown."""
|
|
|
|
def __init__(
|
|
self,
|
|
app_key: str = "",
|
|
app_secret: str = "",
|
|
robot_code: str = "",
|
|
worker_count: int = 4,
|
|
builtin_tools: list[str] | str | bool = False,
|
|
job_tools: list[str] | None = None,
|
|
**kwargs,
|
|
):
|
|
super().__init__(**kwargs)
|
|
self.app_key = app_key
|
|
self.app_secret = app_secret
|
|
self.robot_code = robot_code
|
|
self.worker_count = max(1, worker_count)
|
|
self.builtin_tools = builtin_tools
|
|
self.job_tools = list(job_tools or [])
|
|
|
|
async def execute(self):
|
|
assert self.context is not None
|
|
if self.context.stop_event is None or self.app_context is None:
|
|
raise RuntimeError("dingtalk_wait_step requires an ApplicationContext and background stop_event")
|
|
if self.agent_wrapper is None:
|
|
raise RuntimeError("dingtalk_wait_step requires an agent_wrapper")
|
|
if not self.app_key or not self.app_secret or not self.robot_code:
|
|
raise RuntimeError("dingtalk_wait_step requires app_key, app_secret, and robot_code")
|
|
|
|
import dingtalk_stream # pylint: disable=import-outside-toplevel
|
|
|
|
queue: asyncio.Queue = asyncio.Queue()
|
|
handler = self._make_handler(dingtalk_stream, queue)
|
|
client = dingtalk_stream.DingTalkStreamClient(
|
|
dingtalk_stream.Credential(self.app_key, self.app_secret),
|
|
)
|
|
client.register_callback_handler(dingtalk_stream.ChatbotMessage.TOPIC, handler)
|
|
sessions = self.app_context.metadata.setdefault("dingtalk_agent_sessions", {})
|
|
locks: dict[str, asyncio.Lock] = {}
|
|
self.logger.info(
|
|
f"[{self.name}] starting DingTalk Stream bridge workers={self.worker_count}",
|
|
)
|
|
workers = [asyncio.create_task(self._worker(queue, locks, sessions, handler)) for _ in range(self.worker_count)]
|
|
try:
|
|
await self._run_with_reconnect(client, self.context.stop_event)
|
|
finally:
|
|
for worker in workers:
|
|
worker.cancel()
|
|
await asyncio.gather(*workers, return_exceptions=True)
|
|
self.logger.info(f"[{self.name}] DingTalk Stream bridge stopped")
|
|
return self.context.response
|
|
|
|
@staticmethod
|
|
def _make_handler(dingtalk_stream, queue: asyncio.Queue):
|
|
class QueueHandler(dingtalk_stream.ChatbotHandler):
|
|
"""Acknowledge callbacks after placing them on the worker queue."""
|
|
|
|
async def process(self, callback):
|
|
"""Enqueue one callback and immediately acknowledge it."""
|
|
queue.put_nowait(dingtalk_stream.ChatbotMessage.from_dict(callback.data))
|
|
return dingtalk_stream.AckMessage.STATUS_OK, "OK"
|
|
|
|
return QueueHandler()
|
|
|
|
async def _worker(self, queue, locks, sessions, handler) -> None:
|
|
while True:
|
|
message = await queue.get()
|
|
try:
|
|
key = _session_key(message)
|
|
async with locks.setdefault(key, asyncio.Lock()):
|
|
await self._handle_message(message, key, sessions, handler)
|
|
except Exception as exc: # A bad message must not disconnect the Stream client.
|
|
self.logger.exception(f"Failed to handle DingTalk message: {exc}")
|
|
await asyncio.to_thread(handler.reply_text, f"处理失败:{exc}", message)
|
|
finally:
|
|
queue.task_done()
|
|
|
|
async def _handle_message(self, message, key, sessions, handler) -> None:
|
|
session_ref = _session_ref(key)
|
|
self.logger.info(
|
|
f"[{self.name}] handling DingTalk callback session={session_ref} "
|
|
f"conversation_type={message.conversation_type!r} conversation_id={message.conversation_id!r} "
|
|
f"sender_staff_id={message.sender_staff_id!r}",
|
|
)
|
|
if self.robot_code and getattr(message, "robot_code", "") != self.robot_code:
|
|
self.logger.warning(
|
|
f"[{self.name}] rejected DingTalk callback session={session_ref} reason=robot_code_mismatch",
|
|
)
|
|
raise ValueError("DingTalk callback robotCode does not match configured robot_code")
|
|
text = (message.text.content if message.text else "").strip()
|
|
if not text:
|
|
self.logger.info(f"[{self.name}] ignored non-text DingTalk message session={session_ref}")
|
|
await asyncio.to_thread(handler.reply_text, "暂时只支持文本消息。", message)
|
|
return
|
|
command = await handle_session_command(self.agent_wrapper, text, sessions.get(key))
|
|
if command is not None:
|
|
if command.session_id is None:
|
|
sessions.pop(key, None)
|
|
else:
|
|
sessions[key] = command.session_id
|
|
self.logger.info(f"[{self.name}] handled session command session={session_ref} command={text}")
|
|
await asyncio.to_thread(handler.reply_text, command.answer, message)
|
|
return
|
|
|
|
resumed = key in sessions
|
|
self.logger.info(
|
|
f"[{self.name}] received DingTalk text session={session_ref} chars={len(text)} resume={resumed}",
|
|
)
|
|
kwargs = {"resume": sessions[key]} if key in sessions else {}
|
|
await self._handle_reply(message, key, sessions, handler, text, kwargs, session_ref)
|
|
|
|
async def _handle_reply(self, message, key, sessions, handler, text, kwargs, session_ref) -> None:
|
|
"""Wait for the final Agent response and send one DingTalk Markdown reply."""
|
|
started_at = time.monotonic()
|
|
try:
|
|
result = await self.agent_wrapper.reply(
|
|
text,
|
|
**kwargs,
|
|
builtin_tools=self.builtin_tools,
|
|
job_tools=self.job_tools,
|
|
)
|
|
if not isinstance(result, dict):
|
|
raise TypeError("Agent reply must be a dictionary")
|
|
if session_id := result.get("session_id"):
|
|
sessions[key] = session_id
|
|
|
|
last_message = result.get("last_message")
|
|
if isinstance(last_message, dict) and last_message.get("is_error"):
|
|
raise RuntimeError("Agent 执行失败")
|
|
|
|
answer = result.get("result")
|
|
if not isinstance(answer, str) or not (answer := answer.strip()):
|
|
raise ValueError("Agent 返回了空回复")
|
|
response = await asyncio.to_thread(handler.reply_markdown, "ReMe Agent", answer, message)
|
|
if response is None:
|
|
raise RuntimeError("发送钉钉 Markdown 回复失败")
|
|
self.logger.info(
|
|
f"[{self.name}] completed DingTalk reply session={session_ref} success=True "
|
|
f"chars={len(answer)} elapsed={time.monotonic() - started_at:.2f}s",
|
|
)
|
|
except Exception:
|
|
self.logger.warning(
|
|
f"[{self.name}] DingTalk reply failed session={session_ref} "
|
|
f"elapsed={time.monotonic() - started_at:.2f}s",
|
|
)
|
|
raise
|
|
|
|
async def _run_with_reconnect(self, client, stop_event: asyncio.Event) -> None:
|
|
"""Reconnect cleanly when DingTalk rotates an otherwise healthy connection."""
|
|
while not stop_event.is_set():
|
|
disconnect_reason = await self._run_client(client, stop_event)
|
|
if disconnect_reason is None:
|
|
return
|
|
self.logger.info(
|
|
f"[{self.name}] DingTalk server requested reconnect reason={disconnect_reason!r}; "
|
|
"reconnecting in 1.0s",
|
|
)
|
|
try:
|
|
await asyncio.wait_for(stop_event.wait(), timeout=1.0)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
|
|
@staticmethod
|
|
async def _run_client(client, stop_event: asyncio.Event) -> str | None:
|
|
"""Run one connection and return a server-requested disconnect reason."""
|
|
import websockets # pylint: disable=import-outside-toplevel
|
|
|
|
client.pre_start()
|
|
connection = await asyncio.to_thread(client.open_connection)
|
|
if not connection:
|
|
raise ConnectionError("DingTalk open connection failed")
|
|
uri = f'{connection["endpoint"]}?ticket={quote_plus(connection["ticket"])}'
|
|
disconnect_reason = None
|
|
async with websockets.connect(uri) as websocket:
|
|
client.websocket = websocket
|
|
keepalive = asyncio.create_task(client.keepalive(websocket))
|
|
|
|
async def close_when_stopped() -> None:
|
|
await stop_event.wait()
|
|
await websocket.close()
|
|
|
|
stopper = asyncio.create_task(close_when_stopped())
|
|
try:
|
|
async for raw_message in websocket:
|
|
message = json.loads(raw_message)
|
|
if await client.route_message(message) == client.TAG_DISCONNECT:
|
|
data = message.get("data", {})
|
|
if isinstance(data, str):
|
|
try:
|
|
data = json.loads(data)
|
|
except json.JSONDecodeError:
|
|
data = {}
|
|
reason = data.get("reason") if isinstance(data, dict) else None
|
|
disconnect_reason = (
|
|
reason.strip() if isinstance(reason, str) and reason.strip() else "unspecified"
|
|
)
|
|
await websocket.close()
|
|
break
|
|
finally:
|
|
for task in (stopper, keepalive):
|
|
task.cancel()
|
|
await asyncio.gather(stopper, keepalive, return_exceptions=True)
|
|
if stop_event.is_set():
|
|
return None
|
|
if disconnect_reason is not None:
|
|
return disconnect_reason
|
|
raise ConnectionError("DingTalk WebSocket closed unexpectedly")
|