"""Story 路由候选 JSON:排序、summary 优先、预算降级。""" from __future__ import annotations import json from datetime import datetime, timezone from types import SimpleNamespace from app.agents.memoir.story_route_payload import ( build_route_candidate_json, build_route_candidate_rows, sort_stories_for_route, _truncate_body_for_route, ) from app.core.config import Settings def _story(**kwargs): defaults = dict( id="s-default", title="T", summary=None, canonical_markdown="", updated_at=None, chapter_links=[], ) defaults.update(kwargs) return SimpleNamespace(**defaults) def test_sort_has_summary_first_then_recency(): older = _story( id="old", summary="x" * 40, updated_at=datetime(2020, 1, 1, tzinfo=timezone.utc), ) newer = _story( id="new", summary="", canonical_markdown="body", updated_at=datetime(2025, 1, 1, tzinfo=timezone.utc), ) meta = { "old": {"char_count": 10, "version_count": 1}, "new": {"char_count": 20, "version_count": 2}, } out = sort_stories_for_route([newer, older], meta, summary_min_chars=30) assert [s.id for s in out] == ["old", "new"] def test_sort_tiebreak_version_then_char_then_id(): t = datetime(2024, 6, 1, tzinfo=timezone.utc) a = _story(id="a", summary="", canonical_markdown="a", updated_at=t) b = _story(id="b", summary="", canonical_markdown="bb", updated_at=t) meta = { "a": {"char_count": 100, "version_count": 1}, "b": {"char_count": 50, "version_count": 3}, } out = sort_stories_for_route([a, b], meta, summary_min_chars=30) assert [s.id for s in out] == ["b", "a"] def test_summary_sufficient_omits_body(): s = _story( id="1", summary="信" * 40, canonical_markdown="正文" * 500, ) settings = Settings() rows = build_route_candidate_rows( [s], {"1": {"char_count": 10, "version_count": 1}}, settings ) assert "summary" in rows[0] assert "body_for_route" not in rows[0] def test_short_summary_falls_back_to_body(): s = _story( id="1", summary="短", canonical_markdown="唯一的正文用于路由", ) settings = Settings() rows = build_route_candidate_rows( [s], {"1": {"char_count": 20, "version_count": 1}}, settings ) assert "summary" not in rows[0] assert rows[0].get("body_for_route") def test_long_body_uses_head_tail(): md = "块" * 3000 out = _truncate_body_for_route( md, body_max_chars=1600, head_chars=100, tail_chars=100, ) assert "中间省略" in out assert len(out) < len(md) def test_total_budget_downgrades_tail_rows(monkeypatch): settings = Settings() monkeypatch.setattr(settings, "story_route_candidate_total_max_chars", 800) monkeypatch.setattr(settings, "story_route_index_preview_chars", 40) stories = [ _story( id="1", summary="", canonical_markdown="A" * 400, updated_at=datetime(2025, 1, 2, tzinfo=timezone.utc), ), _story( id="2", summary="", canonical_markdown="B" * 400, updated_at=datetime(2025, 1, 1, tzinfo=timezone.utc), ), ] meta = { "1": {"char_count": 400, "version_count": 1}, "2": {"char_count": 400, "version_count": 1}, } payload = build_route_candidate_json(stories, meta, settings) data = json.loads(payload) assert any("preview" in row and "body_for_route" not in row for row in data) def test_json_includes_core_fields(): s = _story( id="x1", title="标题", summary="y" * 40, updated_at=datetime(2024, 1, 1, tzinfo=timezone.utc), ) settings = Settings() js = build_route_candidate_json( [s], {"x1": {"char_count": 5, "version_count": 2}}, settings ) row = json.loads(js)[0] assert row["id"] == "x1" assert row["title"] == "标题" assert row["version_count"] == 2 assert row["char_count"] == 5 assert "updated_at" in row