147 lines
4.1 KiB
Python
147 lines
4.1 KiB
Python
|
|
"""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
|