mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-07 08:26:06 +00:00
Some checks failed
CI / Python packages / Build and verify distributions (push) Has been cancelled
CI / Python tests / Unit Tests - py3.12 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.13 (push) Has been cancelled
CI / ReMe Studio / Studio checks (push) Has been cancelled
CI / TypeScript integrations / Type-check, test, and pack (push) Has been cancelled
CI / Windows / CLI smoke - py3.11 (push) Has been cancelled
Deploy / Documentation / Build documentation (push) Has been cancelled
CI / Python quality / Pre-commit (push) Has been cancelled
Security / CodeQL / Analyze javascript-typescript (push) Has been cancelled
Security / CodeQL / Analyze python (push) Has been cancelled
CI / Documentation / Test and build documentation (push) Has been cancelled
CI / Python tests / Unit Tests - py3.11 (push) Has been cancelled
Deploy / Documentation / deploy (push) Has been cancelled
* chore(release): prepare ReMe 0.4.1.9 * refactor(config): remove daily_cookbook and streamline plugin configs - Delete the entire daily_cookbook.yaml standalone application config - Remove qwenpaw dependencies verification and related CI workflow steps - Simplify release workflows by removing qwenpaw verification and enforcing reme-ai >=0.4.1.9 - Update plugin start commands and examples to use 'default' or 'demo' configs instead of daily_cookbook - Adjust imports and tests related to daily_cookbook removal and injected_job_kwargs enhancements - Refactor agent wrapper to support injected_job_kwargs for job parameter injection in auto-fin and daily-paper - Improve daily_paper digest prompt to include configured daily directory and correct historical search constraints - Update dependency versions in pyproject.toml files to require reme-ai >=0.4.1.9 and remove qwenpaw optional dependencies - Clean up unused environment variables and obsolete test cases related to daily_cookbook and verification steps * fix(local_embedding_store): retry batch computation on vector space changes - Add up to 3 attempts to recompute embedding batch if vector space changes during processing - Log warnings when maximum retries reached and discard stale results - Prevent caching results from outdated vector spaces to maintain consistency - Add tests to verify retry behavior and abort after continuous vector space churn fix(daily_paper): update digest search logic and tests - Change search to query existing memory, not only previous articles in daily_dir - Allow multiple searches outside daily_dir but limit links to dated markdown in daily_dir before today - Update test assertions to reflect revised search and linking rules * fix(embedding): retry vector space changes per request
286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""Focused tests for the rolling CLS Auto Fin workflow."""
|
||
|
||
# pylint: disable=missing-function-docstring,protected-access
|
||
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import pytest
|
||
import yaml
|
||
|
||
from reme_auto_fin.base import _plain_text, _write
|
||
from reme_auto_fin.data import AutoFinDataStep
|
||
from reme_auto_fin.merge import AutoFinMergeStep
|
||
from reme_auto_fin.schema import AutoFinReportOutput, AutoFinTopicOutput
|
||
from reme_auto_fin.topic import AutoFinTopicStep
|
||
from reme.components import ApplicationContext
|
||
from reme.components.agent_wrapper.base_agent_wrapper import BaseAgentWrapper
|
||
from reme.components.runtime_context import RuntimeContext
|
||
|
||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||
PLUGIN_MANIFEST = yaml.safe_load(
|
||
(Path(__file__).parents[1] / "src" / "reme_auto_fin" / "plugin.yaml").read_text(encoding="utf-8"),
|
||
)
|
||
|
||
|
||
def _row(news_id: int, value: datetime, title: str = "新闻", content: str = "正文") -> dict:
|
||
return {"id": news_id, "ctime": int(value.timestamp()), "title": title, "content": content}
|
||
|
||
|
||
def test_atomic_write_preserves_existing_file_on_failure(tmp_path: Path, monkeypatch):
|
||
path = tmp_path / "result.md"
|
||
path.write_text("existing", encoding="utf-8")
|
||
monkeypatch.setattr(
|
||
"reme_auto_fin.base.os.replace",
|
||
lambda *_args: (_ for _ in ()).throw(OSError()),
|
||
)
|
||
|
||
with pytest.raises(OSError):
|
||
_write(path, "replacement")
|
||
|
||
assert path.read_text(encoding="utf-8") == "existing"
|
||
assert not list(tmp_path.glob(".*.tmp"))
|
||
assert _plain_text("<p>甲&乙</p><style>隐藏</style><p>丙</p>") == "甲&乙 丙"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_data_step_fetches_exact_24_hours_with_default_topics(tmp_path: Path, monkeypatch):
|
||
end = datetime(2026, 8, 10, 9, 30, tzinfo=SHANGHAI)
|
||
|
||
async def page(_self, _client, _last_time):
|
||
return [
|
||
_row(1, end, "黄金上涨"),
|
||
_row(2, end.replace(day=9), "窗口边界"),
|
||
_row(3, end.replace(day=9, minute=29), "窗口之外"),
|
||
_row(1, end, "重复"),
|
||
]
|
||
|
||
monkeypatch.setattr(AutoFinDataStep, "_request_page", page)
|
||
context = RuntimeContext(date="2026-08-10", now=end.isoformat(), topics="")
|
||
response = await AutoFinDataStep(
|
||
app_context=ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai"),
|
||
request_interval=0,
|
||
)(context)
|
||
|
||
assert [row["news_id"] for row in context["auto_fin_news"]] == ["2", "1"]
|
||
assert context["auto_fin_topics"] == ["黄金", "机器人", "半导体"]
|
||
assert context["auto_fin_window_start"] == "2026-08-09T09:30:00+08:00"
|
||
assert response.metadata["fetched_news_count"] == 2
|
||
assert not list(tmp_path.rglob("*.md"))
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_data_step_uses_configurable_window_hours(tmp_path: Path, monkeypatch):
|
||
end = datetime(2026, 8, 10, 9, 30, tzinfo=SHANGHAI)
|
||
|
||
async def page(_self, _client, _last_time):
|
||
return [
|
||
_row(1, end, "窗口内"),
|
||
_row(2, end.replace(day=9, hour=21, minute=30), "窗口边界"),
|
||
_row(3, end.replace(day=9, hour=21, minute=29), "窗口之外"),
|
||
]
|
||
|
||
monkeypatch.setattr(AutoFinDataStep, "_request_page", page)
|
||
context = RuntimeContext(date="2026-08-10", now=end.isoformat(), topics="黄金", window_hours=12)
|
||
await AutoFinDataStep(
|
||
app_context=ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai"),
|
||
request_interval=0,
|
||
)(context)
|
||
|
||
assert [row["news_id"] for row in context["auto_fin_news"]] == ["2", "1"]
|
||
assert context["auto_fin_window_start"] == "2026-08-09T21:30:00+08:00"
|
||
assert context["auto_fin_window_hours"] == 12
|
||
|
||
|
||
class _TopicAgent(BaseAgentWrapper):
|
||
def __init__(self, news_ids: list[str], **kwargs):
|
||
super().__init__(**kwargs)
|
||
self.news_ids = news_ids
|
||
self.calls = []
|
||
|
||
async def reply(self, inputs, **kwargs):
|
||
self.calls.append((str(inputs), kwargs))
|
||
return {"structured_output": AutoFinTopicOutput(news_ids=self.news_ids)}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_topic_step_keeps_real_ids_in_memory_only(tmp_path: Path):
|
||
app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
|
||
agent = _TopicAgent(["2", "missing", "2"], app_context=app_context)
|
||
context = RuntimeContext(
|
||
auto_fin_news=[
|
||
{"news_id": "1", "event_time": "2026-08-10T08:00:00+08:00", "title": "甲", "content": "甲"},
|
||
{"news_id": "2", "event_time": "2026-08-10T09:00:00+08:00", "title": "乙", "content": "乙"},
|
||
],
|
||
auto_fin_topics=["黄金"],
|
||
)
|
||
|
||
response = await AutoFinTopicStep(app_context=app_context, agent_wrapper=agent)(context)
|
||
|
||
assert [row["news_id"] for row in context["auto_fin_selected_news"]] == ["2"]
|
||
assert agent.calls[0][1] == {"output_schema": AutoFinTopicOutput}
|
||
assert response.metadata["relevant_news_count"] == 1
|
||
assert not list(tmp_path.rglob("*.*"))
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_topic_step_marks_empty_selection_as_successful_skip(tmp_path: Path):
|
||
app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
|
||
agent = _TopicAgent([], app_context=app_context)
|
||
context = RuntimeContext(
|
||
auto_fin_news=[
|
||
{"news_id": "1", "event_time": "2026-08-10T08:00:00+08:00", "title": "甲", "content": "甲"},
|
||
],
|
||
auto_fin_topics=["黄金"],
|
||
auto_fin_window_hours=12,
|
||
)
|
||
|
||
response = await AutoFinTopicStep(
|
||
app_context=app_context,
|
||
agent_wrapper=agent,
|
||
)(context)
|
||
|
||
assert context["auto_fin_skipped"] is True
|
||
assert response.metadata["skipped"] is True
|
||
assert response.answer == "最近12小时没有与 黄金 相关的财联社新闻。"
|
||
assert "最近12小时" in agent.calls[0][0]
|
||
assert not list(tmp_path.rglob("*.md"))
|
||
|
||
|
||
class _ResearchAgent(BaseAgentWrapper):
|
||
def __init__(self, **kwargs):
|
||
super().__init__(**kwargs)
|
||
self.calls = []
|
||
|
||
async def reply(self, inputs, **kwargs):
|
||
self.calls.append((str(inputs), kwargs))
|
||
return {
|
||
"structured_output": AutoFinReportOutput(
|
||
title="# 主题新闻观察",
|
||
description="关注黄金政策变化。",
|
||
body=(
|
||
"## 今日判断\n\n"
|
||
"CLS 1(09:00,黄金上涨)与 "
|
||
"[[daily/2026-08-01/auto_fin.md|历史黄金观察]]"
|
||
"(daily/2026-08-01/auto_fin.md) 背景相似。\n\n"
|
||
"无效引用 [[daily/missing.md|缺失文章]] 和 [[../../outside.md|越界文章]] 应降级。"
|
||
),
|
||
),
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_merge_writes_only_final_report_and_validates_historical_links(tmp_path: Path):
|
||
historical = tmp_path / "daily" / "2026-08-01" / "auto_fin.md"
|
||
historical.parent.mkdir(parents=True)
|
||
historical.write_text("# 历史黄金观察\n", encoding="utf-8")
|
||
app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
|
||
agent = _ResearchAgent(app_context=app_context)
|
||
context = RuntimeContext(
|
||
auto_fin_date="2026-08-10",
|
||
auto_fin_decision_at="2026-08-10T09:30:00+08:00",
|
||
auto_fin_window_start="2026-08-09T09:30:00+08:00",
|
||
auto_fin_topics=["黄金"],
|
||
auto_fin_selected_news=[
|
||
{
|
||
"news_id": "1",
|
||
"event_time": "2026-08-10T09:00:00+08:00",
|
||
"title": "黄金上涨",
|
||
"content": "避险需求增强",
|
||
},
|
||
],
|
||
)
|
||
|
||
response = await AutoFinMergeStep(
|
||
app_context=app_context,
|
||
agent_wrapper=agent,
|
||
job_tools=["search", "read"],
|
||
)(context)
|
||
|
||
prompt, kwargs = agent.calls[0]
|
||
assert "end_date" not in prompt
|
||
assert "调用 `search`" in prompt
|
||
assert "调用 `read`" in prompt
|
||
assert kwargs == {
|
||
"output_schema": AutoFinReportOutput,
|
||
"job_tools": ["search", "read"],
|
||
"injected_job_kwargs": {
|
||
"limit": 5,
|
||
"min_score": 0.0,
|
||
"start_date": None,
|
||
"end_date": "2026-08-09",
|
||
},
|
||
}
|
||
report = (tmp_path / "daily" / "2026-08-10" / "auto_fin.md").read_text(encoding="utf-8")
|
||
assert "[[daily/2026-08-01/auto_fin.md|历史黄金观察]]" in report
|
||
assert "](daily/2026-08-01/auto_fin.md)" not in report
|
||
assert "缺失文章" in report and "越界文章" in report
|
||
assert "missing.md" not in report and "outside.md" not in report
|
||
assert not (tmp_path / "daily" / "2026-08-10" / "auto_fin_news.md").exists()
|
||
assert not (tmp_path / "resource").exists()
|
||
assert response.metadata["source_paths"] == ["daily/2026-08-01/auto_fin.md"]
|
||
|
||
|
||
def test_hybrid_wikilink_normalization_is_conservative_and_failure_safe(tmp_path: Path, monkeypatch):
|
||
import reme_auto_fin.merge as merge_module
|
||
|
||
step = AutoFinMergeStep(
|
||
app_context=ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai"),
|
||
)
|
||
body = (
|
||
"[[digest/wiki/gold.md]](digest/wiki/gold.md) "
|
||
"[[digest/wiki/gold.md|黄金]](<digest/wiki/gold.md>) "
|
||
"[[digest/wiki/gold.md#L2|黄金]](digest/wiki/gold.md) "
|
||
"[[digest/wiki/gold.md]](digest/wiki/other.md)"
|
||
)
|
||
assert step._normalize_hybrid_wikilinks(body) == (
|
||
"[[digest/wiki/gold.md]] "
|
||
"[[digest/wiki/gold.md|黄金]] "
|
||
"[[digest/wiki/gold.md#L2|黄金]] "
|
||
"[[digest/wiki/gold.md]](digest/wiki/other.md)"
|
||
)
|
||
|
||
class _BrokenPattern:
|
||
@staticmethod
|
||
def sub(_replace, _body):
|
||
raise RuntimeError("normalization failed")
|
||
|
||
monkeypatch.setattr(merge_module, "_HYBRID_WIKILINK_RE", _BrokenPattern())
|
||
assert step._normalize_hybrid_wikilinks(body) == body
|
||
|
||
|
||
def test_plugin_config_has_default_topics_and_no_intermediate_index_step():
|
||
jobs = PLUGIN_MANIFEST["application_defaults"]["jobs"]
|
||
job = jobs["auto_fin"]
|
||
assert job["parameters"]["properties"]["topics"]["default"] == "黄金,机器人,半导体"
|
||
assert job["parameters"]["properties"]["window_hours"]["default"] == 24
|
||
assert job["parameters"]["properties"]["request_interval"]["default"] == 10
|
||
assert job["parameters"]["properties"]["max_retries"]["default"] == 3
|
||
assert "news_file" not in job["parameters"]["properties"]
|
||
assert [step["backend"] for step in job["steps"]] == [
|
||
"auto_fin_data_step",
|
||
"auto_fin_topic_step",
|
||
"auto_fin_merge_step",
|
||
]
|
||
assert job["steps"][2]["job_tools"] == ["search", "read"]
|
||
assert jobs["auto_fin_cron"]["cron"] == "0 18 * * *"
|
||
assert jobs["auto_fin_cron"]["steps"] == job["steps"]
|
||
assert (
|
||
not {
|
||
"auto_fin_0930_cron",
|
||
"auto_fin_1130_cron",
|
||
"auto_fin_1800_cron",
|
||
}
|
||
& jobs.keys()
|
||
)
|
||
|
||
|
||
def test_agent_schemas_are_small_and_required():
|
||
topic = AutoFinTopicOutput.model_json_schema()
|
||
report = AutoFinReportOutput.model_json_schema()
|
||
|
||
assert topic["required"] == ["news_ids"]
|
||
assert set(topic["properties"]) == {"news_ids"}
|
||
assert report["required"] == ["title", "description", "body"]
|
||
assert set(report["properties"]) == {"title", "description", "body"}
|