Backfilling an old week used to sweep in undigested papers published later (stamping them into the wrong week and removing them from the next Monday's digest); regular weekly runs are unaffected since the collection window already ends at week_end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
259 lines
12 KiB
Python
259 lines
12 KiB
Python
"""Pipeline orchestration with fakes: happy path, idempotency, degrade and delivery failures."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from _fakes import NOW, WEEK, FakeDeliverer, FakeProvider, FakeStore, make_digest, make_paper
|
|
|
|
from sebrief import pipeline
|
|
from sebrief.config import Settings
|
|
from sebrief.models import Verification, VerificationIssue
|
|
from sebrief.pipeline import SKIP_ALREADY_SENT, build_payload, delivery_failed, run_digest
|
|
|
|
|
|
@pytest.fixture
|
|
def env(monkeypatch, tmp_path):
|
|
"""Patch every pipeline collaborator; return the mutable test context."""
|
|
ctx = {
|
|
"store": FakeStore(pending=[make_paper()]),
|
|
"provider": FakeProvider(),
|
|
"deliverers": [FakeDeliverer("file")],
|
|
"openalex": [make_paper()],
|
|
"rss": [],
|
|
"settings": Settings(_env_file=None, SEBRIEF_DATA_DIR=str(tmp_path / "data")),
|
|
}
|
|
ctx["crossref"] = []
|
|
monkeypatch.setattr(pipeline, "_now", lambda: NOW)
|
|
monkeypatch.setattr(pipeline, "Store", lambda path: ctx["store"])
|
|
monkeypatch.setattr(pipeline, "fetch_openalex_papers", lambda start, end, s: ctx["openalex"])
|
|
monkeypatch.setattr(pipeline, "fetch_crossref_papers", lambda start, end, s: ctx["crossref"])
|
|
monkeypatch.setattr(pipeline, "fetch_rss_papers", lambda feeds, start, end, bl: ctx["rss"])
|
|
monkeypatch.setattr(pipeline, "build_provider", lambda s: ctx["provider"])
|
|
monkeypatch.setattr(pipeline, "build_deliverers", lambda s: ctx["deliverers"])
|
|
return ctx
|
|
|
|
|
|
# ------------------------------------------------------------------------------ build_payload
|
|
def test_build_payload_collects_upserts_and_reads_pending(env):
|
|
payload = build_payload(WEEK, env["settings"], env["store"])
|
|
assert payload.week == WEEK
|
|
assert env["store"].upserted == [[make_paper()]]
|
|
assert [p.doi for p in payload.papers] == ["10.1145/1234567"]
|
|
assert payload.data_gaps == []
|
|
# collection window reaches back by backfill_days
|
|
assert payload.week_start.isoformat() == "2026-08-31"
|
|
|
|
|
|
def test_build_payload_collector_failure_becomes_gap(env, monkeypatch):
|
|
def boom(start, end, s):
|
|
raise RuntimeError("openalex down")
|
|
|
|
monkeypatch.setattr(pipeline, "fetch_openalex_papers", boom)
|
|
payload = build_payload(WEEK, env["settings"], env["store"])
|
|
assert payload.data_gaps == ["openalex: RuntimeError: openalex down"]
|
|
assert env["store"].upserted == [] # nothing collected -> nothing upserted
|
|
assert [p.doi for p in payload.papers] == ["10.1145/1234567"] # pending leftovers still flow in
|
|
|
|
|
|
def test_build_payload_merges_rss_dedup_by_doi(env):
|
|
env["settings"] = env["settings"].model_copy(
|
|
update={"rss_feeds": ["e-Inf|https://example.org/rss"]}
|
|
)
|
|
dup = make_paper(source="rss:e-Inf", venue="e-Inf") # same DOI as openalex
|
|
fresh = make_paper("10.5555/rss-only", source="rss:e-Inf")
|
|
env["rss"] = [dup, fresh]
|
|
build_payload(WEEK, env["settings"], env["store"])
|
|
[upserted] = env["store"].upserted
|
|
assert [p.doi for p in upserted] == ["10.1145/1234567", "10.5555/rss-only"]
|
|
assert upserted[0].source == "openalex" # openalex metadata wins on the duplicate
|
|
|
|
|
|
def test_build_payload_merges_crossref_openalex_wins_duplicates(env):
|
|
dup = make_paper(source="crossref:tse", venue="TSE", abstract="") # same DOI as openalex
|
|
fresh = make_paper("10.1109/tse.2026.1", source="crossref:tse", abstract="")
|
|
env["crossref"] = [dup, fresh]
|
|
build_payload(WEEK, env["settings"], env["store"])
|
|
[upserted] = env["store"].upserted
|
|
assert [p.doi for p in upserted] == ["10.1145/1234567", "10.1109/tse.2026.1"]
|
|
assert upserted[0].source == "openalex" # richer OpenAlex metadata wins the duplicate
|
|
|
|
|
|
def test_build_payload_invalid_feed_spec_is_a_gap(env):
|
|
env["settings"] = env["settings"].model_copy(update={"rss_feeds": ["broken-spec"]})
|
|
payload = build_payload(WEEK, env["settings"], env["store"])
|
|
assert any("invalid feed spec" in g for g in payload.data_gaps)
|
|
|
|
|
|
def test_backfill_excludes_papers_published_after_the_week(env):
|
|
# 과거 주 소급 생성: 그 주 이후에 발행된 미소화 논문은 과거 다이제스트로 끌려가면 안 된다
|
|
from datetime import date
|
|
|
|
in_week = make_paper("10.1/inweek", published_date=date(2026, 9, 2))
|
|
later = make_paper("10.1/later", published_date=date(2026, 9, 20)) # W36 이후 발행
|
|
undated = make_paper("10.1/undated", published_date=None)
|
|
env["store"].pending = [in_week, later, undated]
|
|
payload = build_payload(WEEK, env["settings"], env["store"]) # WEEK = 2026-W35 (…~09-06)
|
|
assert [p.doi for p in payload.papers] == ["10.1/inweek", "10.1/undated"]
|
|
|
|
|
|
def test_build_payload_caps_papers_and_notes_rollover(env):
|
|
env["store"].pending = [make_paper(f"10.1/p{i}") for i in range(5)]
|
|
env["settings"] = env["settings"].model_copy(update={"max_papers": 3})
|
|
payload = build_payload(WEEK, env["settings"], env["store"])
|
|
assert len(payload.papers) == 3
|
|
assert [p.doi for p in payload.papers] == ["10.1/p0", "10.1/p1", "10.1/p2"]
|
|
assert any("이월" in g for g in payload.data_gaps)
|
|
|
|
|
|
def test_cap_never_drops_papers_already_in_this_weeks_digest(env):
|
|
# --force re-run: p3/p4 were published in this week's digest; the cap must drop rollover
|
|
# candidates (p0..p2) instead, even though they sort first.
|
|
env["store"].pending = [make_paper(f"10.1/p{i}") for i in range(5)]
|
|
env["store"].published_dois = {"10.1/p3", "10.1/p4"}
|
|
env["settings"] = env["settings"].model_copy(update={"max_papers": 3})
|
|
payload = build_payload(WEEK, env["settings"], env["store"])
|
|
dois = [p.doi for p in payload.papers]
|
|
assert "10.1/p3" in dois and "10.1/p4" in dois
|
|
assert len(dois) == 3 and dois == ["10.1/p0", "10.1/p3", "10.1/p4"]
|
|
|
|
|
|
def test_cap_keeps_all_published_even_beyond_max(env):
|
|
env["store"].pending = [make_paper(f"10.1/p{i}") for i in range(4)]
|
|
env["store"].published_dois = {f"10.1/p{i}" for i in range(4)}
|
|
env["settings"] = env["settings"].model_copy(update={"max_papers": 2})
|
|
payload = build_payload(WEEK, env["settings"], env["store"])
|
|
assert len(payload.papers) == 4 # a published paper is never dropped
|
|
assert not any("이월" in g for g in payload.data_gaps)
|
|
|
|
|
|
# -------------------------------------------------------------------------------- run_digest
|
|
def test_run_digest_happy_path_saves_marks_and_delivers(env):
|
|
result = run_digest(WEEK, env["settings"])
|
|
assert result.error is None and not result.degraded
|
|
assert result.model_used == "fake-model"
|
|
assert result.verification is not None and result.verification.ok
|
|
assert WEEK in env["store"].digests
|
|
assert env["store"].digested == [(["10.1145/1234567"], WEEK)]
|
|
assert env["store"].sent == [(WEEK, "file", "file-receipt-1")]
|
|
assert env["store"].closed is True
|
|
assert "SE 주간 브리핑" in result.markdown
|
|
# per-paper summaries were generated, attached, persisted and rendered
|
|
assert result.payload.papers[0].summary is not None
|
|
assert env["store"].saved_summaries == [{"10.1145/1234567": result.payload.papers[0].summary}]
|
|
assert "## 논문별 요약" in result.markdown
|
|
|
|
|
|
def test_run_digest_paper_summaries_disabled(env):
|
|
env["settings"] = env["settings"].model_copy(update={"paper_summary_enabled": False})
|
|
result = run_digest(WEEK, env["settings"])
|
|
assert result.payload.papers[0].summary is None
|
|
assert env["store"].saved_summaries == []
|
|
assert not any(c.startswith("summarize_papers") for c in env["provider"].calls)
|
|
|
|
|
|
def test_run_digest_paper_summary_failure_is_a_gap_not_a_blocker(env):
|
|
env["provider"] = FakeProvider(fail_paper_summaries=True)
|
|
result = run_digest(WEEK, env["settings"])
|
|
assert result.error is None and not result.degraded # digest itself still produced
|
|
assert any("paper-summaries" in g for g in result.payload.data_gaps)
|
|
assert env["store"].saved_summaries == []
|
|
|
|
|
|
def test_run_digest_defaults_to_latest_completed_week(env):
|
|
result = run_digest(None, env["settings"])
|
|
assert result.payload.week == WEEK # NOW is Monday 2026-09-07 -> last completed week is W36
|
|
|
|
|
|
def test_run_digest_skips_when_already_sent(env):
|
|
env["store"].digests[WEEK] = object()
|
|
result = run_digest(WEEK, env["settings"])
|
|
assert result.error == SKIP_ALREADY_SENT
|
|
assert env["store"].sent == []
|
|
|
|
|
|
def test_run_digest_force_resends(env):
|
|
env["store"].digests[WEEK] = object()
|
|
result = run_digest(WEEK, env["settings"], force=True)
|
|
assert result.error is None
|
|
assert env["store"].sent == [(WEEK, "file", "file-receipt-1")]
|
|
|
|
|
|
def test_run_digest_refuses_unfinished_week(env):
|
|
with pytest.raises(ValueError, match="has not ended yet"):
|
|
run_digest("2026-W37", env["settings"]) # NOW (Mon 09-07) is inside W37
|
|
|
|
|
|
def test_run_digest_dry_run_previews_without_side_effects(env, capsys):
|
|
result = run_digest("2026-W37", env["settings"], dry_run=True) # even an unfinished week
|
|
assert "SE 주간 브리핑" in capsys.readouterr().out
|
|
assert env["store"].digests == {} and env["store"].sent == [] and env["store"].digested == []
|
|
assert result.markdown
|
|
|
|
|
|
def test_run_digest_llm_failure_degrades_but_still_delivers(env):
|
|
env["provider"] = FakeProvider(fail_summarize=True)
|
|
result = run_digest(WEEK, env["settings"])
|
|
assert result.degraded and result.digest is None
|
|
assert "all models failed" in (result.error or "")
|
|
assert env["store"].sent == [(WEEK, "file", "file-receipt-1")]
|
|
# degraded delivery raises an operator alert
|
|
assert any("축소 모드" in s for s, _ in env["deliverers"][0].errors)
|
|
|
|
|
|
def test_run_digest_no_llm_flag(env):
|
|
result = run_digest(WEEK, env["settings"], no_llm=True)
|
|
assert result.degraded and result.digest is None and result.error is None
|
|
assert env["provider"].calls == []
|
|
|
|
|
|
def test_run_digest_empty_week_is_not_degraded(env):
|
|
env["openalex"] = []
|
|
env["store"].pending = []
|
|
result = run_digest(WEEK, env["settings"])
|
|
assert not result.degraded and result.digest is None and result.error is None
|
|
assert "이번 주 수집된 OA 논문이 없습니다." in result.markdown
|
|
assert env["provider"].calls == []
|
|
assert env["store"].digested == [([], WEEK)]
|
|
|
|
|
|
def test_run_digest_verification_disabled(env):
|
|
env["settings"] = env["settings"].model_copy(update={"verify_enabled": False})
|
|
result = run_digest(WEEK, env["settings"])
|
|
assert result.verification is None
|
|
assert env["provider"].calls == ["summarize_papers:1", "summarize"]
|
|
|
|
|
|
def test_run_digest_verification_revises_major_issue(env):
|
|
major = Verification(ok=False, issues=[VerificationIssue(claim="c", problem="p", severity="major")])
|
|
clean = Verification(ok=True, issues=[])
|
|
env["provider"] = FakeProvider(
|
|
verifications=[major, clean], revised=make_digest(overview="수정본입니다.")
|
|
)
|
|
result = run_digest(WEEK, env["settings"])
|
|
assert result.digest is not None and result.digest.overview == "수정본입니다."
|
|
assert result.verification is not None and result.verification.ok
|
|
|
|
|
|
def test_run_digest_delivery_failure_is_annotated_and_alerted(env):
|
|
failing = FakeDeliverer("file", fail=True)
|
|
env["deliverers"] = [failing]
|
|
result = run_digest(WEEK, env["settings"])
|
|
assert delivery_failed(result)
|
|
assert "delivery failed" in (result.error or "")
|
|
assert WEEK in env["store"].digests # saved before delivering
|
|
assert any("발송 실패" in s for s, _ in failing.errors)
|
|
assert any(f"--week {WEEK}" in d for _, d in failing.errors)
|
|
|
|
|
|
def test_run_digest_one_channel_failing_does_not_block_the_other(env):
|
|
ok = FakeDeliverer("ok")
|
|
env["deliverers"] = [FakeDeliverer("bad", fail=True), ok]
|
|
result = run_digest(WEEK, env["settings"])
|
|
assert ok.delivered and env["store"].sent == [(WEEK, "ok", "ok-receipt-1")]
|
|
assert delivery_failed(result)
|
|
|
|
|
|
def test_run_digest_rejects_malformed_week(env):
|
|
with pytest.raises(ValueError):
|
|
run_digest("2026-36", env["settings"])
|