first commit

This commit is contained in:
jsahn
2026-09-02 14:57:29 +09:00
commit c0b140d27f
60 changed files with 7520 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
"""Shared fakes and payload builders for the orchestration tests."""
from __future__ import annotations
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from sebrief.models import (
Digest,
Highlight,
Paper,
PaperSummary,
PaperSummaryItem,
Pick,
TopicGroup,
TopicPaper,
WeekPayload,
)
KST = ZoneInfo("Asia/Seoul")
# Fixed test timeline: 2026-W36 = 2026-08-31 (Mon) .. 2026-09-06 (Sun); the covering run fires
# Monday 2026-09-07 07:00 KST. NOW is that Monday at noon.
WEEK = "2026-W36"
WEEK_START = date(2026, 8, 31)
WEEK_END = date(2026, 9, 6)
RUN_AT = datetime(2026, 9, 7, 7, 0, tzinfo=KST)
NOW = datetime(2026, 9, 7, 12, 0, tzinfo=KST)
def make_paper(doi: str = "10.1145/1234567", **overrides) -> Paper:
values = {
"doi": doi,
"title": "Test-Driven Digest Generation",
"abstract": "We study how weekly digests are generated. Results show 42% faster reading.",
"authors": ["Alice Kim", "Bob Lee"],
"venue": "ACM Transactions on Software Engineering and Methodology",
"published_date": date(2026, 9, 2),
"oa_url": "https://dl.acm.org/doi/10.1145/1234567",
"pdf_url": None,
"topics": ["Software Engineering Research"],
"source": "openalex",
}
values.update(overrides)
return Paper(**values)
def make_summary(**overrides) -> PaperSummary:
values = {
"tldr": "테스트 주도 다이제스트 생성 기법을 제안합니다.",
"problem": "주간 다이제스트 생성이 느립니다.",
"approach": "저자들은 테스트 주도 생성 기법을 제안합니다.",
"findings": "저자들은 읽기 속도가 42% 빨라졌다고 보고합니다.",
"implication": "다이제스트 파이프라인에 적용해볼 만합니다.",
}
values.update(overrides)
return PaperSummary(**values)
def make_payload(papers: list[Paper] | None = None, gaps: list[str] | None = None) -> WeekPayload:
return WeekPayload(
week=WEEK,
week_start=WEEK_START,
week_end=WEEK_END,
generated_at=NOW,
papers=papers if papers is not None else [make_paper()],
data_gaps=gaps or [],
)
def make_digest(doi: str = "10.1145/1234567", **overrides) -> Digest:
values = {
"overview": "이번 주는 다이제스트 생성 연구가 눈에 띕니다.",
"highlights": [
Highlight(
doi=doi,
title="Test-Driven Digest Generation",
problem="주간 다이제스트 생성이 느립니다.",
approach="저자들은 테스트 주도 생성 기법을 제안합니다.",
findings="저자들은 읽기 속도가 42% 빨라졌다고 보고합니다.",
implication="다이제스트 파이프라인에 적용해볼 만합니다.",
)
],
"by_topic": [
TopicGroup(
topic="도구·데이터셋",
papers=[TopicPaper(doi=doi, title="Test-Driven Digest Generation", note="다이제스트 생성 도구 연구입니다.")],
)
],
"worth_reading": [Pick(doi=doi, title="Test-Driven Digest Generation", reason="방법론이 참신합니다.")],
"data_gaps": [],
}
values.update(overrides)
return Digest(**values)
class FakeClock:
"""Deterministic stand-in for sebrief.week.WeeklyClock."""
def __init__(
self,
*,
catchup: str | None = None,
next_run: tuple[str, datetime] | None = None,
) -> None:
self.catchup = catchup
self._next_run = next_run
self.calls: list[tuple[str, object]] = []
def next_run(self, now: datetime) -> tuple[str, datetime]:
self.calls.append(("next_run", now))
if self._next_run is not None:
return self._next_run
# default: the Monday 07:00 after `now`, covering the week before it
d = now.astimezone(KST).date()
while d.weekday() != 0:
d += timedelta(days=1)
run_at = datetime(d.year, d.month, d.day, 7, 0, tzinfo=KST)
if run_at <= now:
run_at += timedelta(days=7)
from sebrief.week import week_id
return week_id(run_at.date() - timedelta(days=7)), run_at
def latest_completed_week(self, now: datetime) -> str:
from sebrief.week import week_id
return week_id(now.astimezone(KST).date() - timedelta(days=7))
def catchup_candidate(self, now: datetime, window_hours: int) -> str | None:
self.calls.append(("catchup_candidate", (now, window_hours)))
return self.catchup
class FakeStore:
"""In-memory stand-in for sebrief.store.Store."""
def __init__(self, *, pending: list[Paper] | None = None) -> None:
self.upserted: list[list[Paper]] = []
self.saved_summaries: list[dict] = []
self.pending = pending if pending is not None else []
self.digests: dict[str, object] = {}
self.digested: list[tuple[list[str], str]] = []
self.sent: list[tuple[str, str, str]] = []
self.closed = False
def upsert_papers(self, papers: list[Paper]) -> int:
self.upserted.append(list(papers))
return len(papers)
def save_paper_summaries(self, summaries: dict) -> None:
self.saved_summaries.append(dict(summaries))
def pending_papers(self, week: str) -> list[Paper]:
return list(self.pending)
def mark_digested(self, dois: list[str], week: str) -> None:
self.digested.append((list(dois), week))
def has_digest(self, week: str) -> bool:
return week in self.digests
def save_digest(self, result) -> None:
self.digests[result.payload.week] = result
def mark_sent(self, week: str, channel: str, receipt: str) -> None:
self.sent.append((week, channel, receipt))
def close(self) -> None:
self.closed = True
class FakeDeliverer:
def __init__(self, name: str = "fake", *, fail: bool = False) -> None:
self.name = name
self.fail = fail
self.delivered: list = []
self.errors: list[tuple[str, str]] = []
def deliver(self, result) -> str:
if self.fail:
raise RuntimeError(f"{self.name} is down")
self.delivered.append(result)
return f"{self.name}-receipt-{len(self.delivered)}"
def notify_error(self, subject: str, detail: str) -> None:
self.errors.append((subject, detail))
class FakeProvider:
"""LLMProvider stand-in: returns canned digest / verification objects."""
def __init__(
self,
digest: Digest | None = None,
verifications: list | None = None,
revised: Digest | None = None,
fail_summarize: bool = False,
fail_revise: bool = False,
fail_paper_summaries: bool = False,
) -> None:
self.digest = digest or make_digest()
self.verifications = list(verifications or [])
self.revised = revised
self.fail_summarize = fail_summarize
self.fail_revise = fail_revise
self.fail_paper_summaries = fail_paper_summaries
self.calls: list[str] = []
def summarize(self, payload):
self.calls.append("summarize")
if self.fail_summarize:
from sebrief.llm.base import LLMError
raise LLMError("all models failed")
return self.digest, "fake-model"
def summarize_papers(self, papers):
self.calls.append(f"summarize_papers:{len(papers)}")
if self.fail_paper_summaries:
from sebrief.llm.base import LLMError
raise LLMError("paper batch failed")
items = [
PaperSummaryItem(doi=p.doi, **make_summary(tldr=f"{p.doi} 한 줄 요약입니다.").model_dump())
for p in papers
]
return items, "fake-model"
def verify(self, payload, digest):
self.calls.append("verify")
if not self.verifications:
from sebrief.models import Verification
return Verification(ok=True, issues=[]), "fake-model"
return self.verifications.pop(0), "fake-model"
def revise(self, payload, digest, issues):
self.calls.append("revise")
if self.fail_revise:
from sebrief.llm.base import LLMError
raise LLMError("revise failed")
return self.revised or self.digest, "fake-model"
+138
View File
@@ -0,0 +1,138 @@
"""CLI: argument parsing, exit codes, config masking, path resolution."""
from __future__ import annotations
import pytest
from _fakes import make_payload
from sebrief import cli
from sebrief.config import Settings
from sebrief.models import DigestResult
from sebrief.pipeline import DELIVERY_FAILED_PREFIX, SKIP_ALREADY_SENT
def make_settings(tmp_path) -> Settings:
return Settings(
_env_file=None,
SEBRIEF_DATA_DIR=str(tmp_path / "data"),
SEBRIEF_REPORTS_DIR=str(tmp_path / "reports"),
SEBRIEF_LOG_DIR=str(tmp_path / "logs"),
)
def make_result(error: str | None = None, degraded: bool = False) -> DigestResult:
return DigestResult(payload=make_payload(), digest=None, markdown="# md", degraded=degraded, error=error)
@pytest.fixture
def run(monkeypatch, tmp_path):
"""Invoke the CLI with settings pinned to tmp_path and run_digest captured."""
calls: dict = {}
def fake_run_digest(week, settings, *, dry_run=False, force=False, no_llm=False):
calls.update(week=week, dry_run=dry_run, force=force, no_llm=no_llm)
return calls.get("result") or make_result()
monkeypatch.setattr(cli, "_load_settings", lambda: make_settings(tmp_path))
monkeypatch.setattr(cli, "run_digest", fake_run_digest)
def invoke(*argv: str, result: DigestResult | None = None) -> int:
if result is not None:
calls["result"] = result
return cli.main(list(argv))
invoke.calls = calls # type: ignore[attr-defined]
return invoke
# ---------------------------------------------------------------------------------- parsing
def test_parser_requires_command():
with pytest.raises(SystemExit):
cli.build_parser().parse_args([])
def test_parser_rejects_bad_week():
with pytest.raises(SystemExit):
cli.build_parser().parse_args(["run-once", "--week", "2026-09-01"])
def test_run_once_flags_forwarded(run):
assert run("run-once", "--week", "2026-W30", "--dry-run", "--force", "--no-llm") == cli.EXIT_OK
assert run.calls["week"] == "2026-W30"
assert run.calls["dry_run"] and run.calls["force"] and run.calls["no_llm"]
# ------------------------------------------------------------------------------- exit codes
def test_run_once_ok(run):
assert run("run-once") == cli.EXIT_OK
def test_run_once_skip_exits_2(run, capsys):
assert run("run-once", result=make_result(error=SKIP_ALREADY_SENT)) == cli.EXIT_SKIPPED
assert "skipped" in capsys.readouterr().err
def test_run_once_delivery_failure_exits_1(run, capsys):
result = make_result(error=f"{DELIVERY_FAILED_PREFIX}: file: boom")
assert run("run-once", result=result) == cli.EXIT_ERROR
assert "delivery failed" in capsys.readouterr().err
def test_run_once_degraded_still_ok(run):
assert run("run-once", result=make_result(degraded=True, error="llm down")) == cli.EXIT_OK
def test_unexpected_exception_exits_1(run, monkeypatch, capsys):
def boom(*a, **k):
raise RuntimeError("kaboom")
monkeypatch.setattr(cli, "run_digest", boom)
assert run("run-once") == cli.EXIT_ERROR
assert "kaboom" in capsys.readouterr().err
# --------------------------------------------------------------------------------- commands
def test_next_run_prints_week_and_time(run, capsys):
assert run("next-run") == cli.EXIT_OK
out = capsys.readouterr().out
assert "week" in out and "digest at KST" in out
def test_show_config_masks_secrets(monkeypatch, tmp_path, capsys):
settings = make_settings(tmp_path).model_copy(update={"minimax_api_key": "sk-super-secret-key"})
monkeypatch.setattr(cli, "_load_settings", lambda: settings)
assert cli.main(["show-config"]) == cli.EXIT_OK
out = capsys.readouterr().out
assert "sk-super-secret-key" not in out
assert "minimax_api_key = sk-s***" in out
assert "db_path" in out
def test_test_notify_writes_errors_log(monkeypatch, tmp_path, capsys):
monkeypatch.setattr(cli, "_load_settings", lambda: make_settings(tmp_path))
assert cli.main(["test-notify", "--message", "hello"]) == cli.EXIT_OK
assert "sent test notification via file" in capsys.readouterr().out
log_text = (tmp_path / "reports" / "errors.log").read_text(encoding="utf-8")
assert "hello" in log_text
# ------------------------------------------------------------------------------------ paths
def test_resolve_paths_makes_relative_paths_absolute(tmp_path):
settings = Settings(_env_file=None) # default relative data/reports/logs
resolved = cli._resolve_paths(settings, root=tmp_path)
assert resolved.data_dir == tmp_path / "data"
assert resolved.reports_dir == tmp_path / "reports"
assert resolved.log_dir == tmp_path / "logs"
def test_resolve_paths_keeps_absolute(tmp_path):
settings = Settings(_env_file=None, SEBRIEF_DATA_DIR=str(tmp_path / "abs"))
resolved = cli._resolve_paths(settings, root=tmp_path / "elsewhere")
assert resolved.data_dir == tmp_path / "abs"
def test_mask_secret():
assert cli.mask_secret(None) == "(unset)"
assert cli.mask_secret("") == "(unset)"
assert cli.mask_secret("short") == "***"
assert cli.mask_secret("sk-1234567890") == "sk-1***"
+52
View File
@@ -0,0 +1,52 @@
"""Settings loading: CSV splitting, validation, derived properties."""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from sebrief.config import Settings
def make_settings(**overrides) -> Settings:
return Settings(_env_file=None, **overrides)
def test_defaults():
s = make_settings()
assert s.minimax_model == "MiniMax-M3"
assert s.minimax_fallback_models == ["MiniMax-M2.7", "MiniMax-M2.5"]
assert s.run_weekday == 0
assert s.run_time_kst == "07:00"
assert s.catchup_window_hours == 72
assert s.max_papers == 40
assert s.backfill_days == 7
assert s.verify_enabled is True
assert s.rss_feeds == []
assert s.db_path == Path("data") / "sebrief.sqlite3"
def test_csv_fields_split():
s = make_settings(
MINIMAX_FALLBACK_MODELS=" a , b ,",
SEBRIEF_RSS_FEEDS="e-Informatica|https://example.org/rss, X|https://x.example/feed",
SEBRIEF_EXTRA_BLACKLIST_ISSNS="1234-5678, 2345-6789",
)
assert s.minimax_fallback_models == ["a", "b"]
assert s.rss_feeds == ["e-Informatica|https://example.org/rss", "X|https://x.example/feed"]
assert s.extra_blacklist_issns == ["1234-5678", "2345-6789"]
def test_run_weekday_validated():
with pytest.raises(ValidationError):
make_settings(SEBRIEF_RUN_WEEKDAY="7")
assert make_settings(SEBRIEF_RUN_WEEKDAY="6").run_weekday == 6
def test_env_aliases(tmp_path):
s = make_settings(SEBRIEF_DATA_DIR=str(tmp_path / "d"), SEBRIEF_MAX_PAPERS="10")
assert s.data_dir == tmp_path / "d"
assert s.max_papers == 10
assert s.db_path == tmp_path / "d" / "sebrief.sqlite3"
+354
View File
@@ -0,0 +1,354 @@
"""Daemon tests with a fake scheduler (captures add_job) and a fake weekly clock."""
from __future__ import annotations
import signal
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from _fakes import NOW, WEEK, FakeClock, FakeDeliverer, FakeStore
from apscheduler.events import EVENT_JOB_MISSED, JobExecutionEvent
from apscheduler.schedulers import SchedulerNotRunningError
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from sebrief import daemon as daemon_mod
from sebrief.config import Settings
from sebrief.daemon import DIGEST_JOB_ID, HEARTBEAT_JOB_ID, RECOVERY_JOB_ID, Daemon
KST = ZoneInfo("Asia/Seoul")
NEXT_RUN = ("2026-W37", datetime(2026, 9, 14, 7, 0, tzinfo=KST))
class FakeScheduler:
def __init__(self):
self.jobs: dict[str, dict] = {}
self.add_calls: list[dict] = []
self.shutdown_calls: list[bool] = []
self.listeners: list[tuple[object, int | None]] = []
def add_listener(self, callback, mask=None):
self.listeners.append((callback, mask))
def add_job(self, func, trigger=None, **kwargs):
entry = {"func": func, "trigger": trigger, **kwargs}
self.add_calls.append(entry)
self.jobs[kwargs.get("id", func.__name__)] = entry
return entry
def shutdown(self, wait=True):
self.shutdown_calls.append(wait)
class RunRecorder:
def __init__(self, fail: bool = False):
self.weeks: list[str] = []
self.fail = fail
def __call__(self, week: str):
self.weeks.append(week)
if self.fail:
raise RuntimeError("pipeline exploded")
def make_settings(tmp_path, **overrides) -> Settings:
values = {"SEBRIEF_DATA_DIR": str(tmp_path / "data"), "SEBRIEF_LOG_DIR": str(tmp_path / "logs")}
values.update(overrides)
return Settings(_env_file=None, **values)
def make_daemon(tmp_path, *, clock=None, store=None, run_fn=None, settings=None, deliverer=None):
scheduler = FakeScheduler()
clock = clock or FakeClock(next_run=NEXT_RUN)
store = store or FakeStore()
run_fn = run_fn or RunRecorder()
deliverer = deliverer or FakeDeliverer("file")
d = Daemon(
settings or make_settings(tmp_path),
scheduler=scheduler,
clock=clock,
store=store,
run_fn=run_fn,
now_fn=lambda: NOW,
deliverers_fn=lambda s: [deliverer],
)
return d, scheduler, clock, store, run_fn, deliverer
# -------------------------------------------------------------------------------- scheduling
def test_schedule_next_schedules_one_shot_job(tmp_path):
d, scheduler, *_ = make_daemon(tmp_path)
assert d.schedule_next() == NEXT_RUN
job = scheduler.jobs[DIGEST_JOB_ID]
assert isinstance(job["trigger"], DateTrigger)
assert job["trigger"].run_date == NEXT_RUN[1]
assert job["args"] == [NEXT_RUN[0], NEXT_RUN[1]]
assert job["replace_existing"] is True
# None: a late (sleep/wake) job must still FIRE so it can reschedule itself
assert job["misfire_grace_time"] is None
def test_schedule_next_survives_clock_error(tmp_path):
class BrokenClock(FakeClock):
def next_run(self, now):
raise RuntimeError("clock broken")
d, scheduler, *_ = make_daemon(tmp_path, clock=BrokenClock())
assert d.schedule_next() is None
assert DIGEST_JOB_ID not in scheduler.jobs
d.clock = FakeClock(next_run=NEXT_RUN)
d._heartbeat_job() # heartbeat retries scheduling
assert DIGEST_JOB_ID in scheduler.jobs
def test_start_schedules_digest_and_heartbeat(tmp_path):
d, scheduler, *_ = make_daemon(tmp_path)
d.start()
assert set(scheduler.jobs) == {DIGEST_JOB_ID, HEARTBEAT_JOB_ID}
hb = scheduler.jobs[HEARTBEAT_JOB_ID]
assert isinstance(hb["trigger"], IntervalTrigger)
assert hb["trigger"].interval.total_seconds() == 3600
assert (d._on_job_missed, EVENT_JOB_MISSED) in scheduler.listeners
# ---------------------------------------------------------------------------------- catch-up
def test_catchup_runs_when_candidate_and_not_yet_digested(tmp_path):
clock = FakeClock(catchup=WEEK, next_run=NEXT_RUN)
d, _scheduler, clock, _store, run_fn, _ = make_daemon(tmp_path, clock=clock)
assert d.run_catchup() is True
assert run_fn.weeks == [WEEK]
assert clock.calls[0] == ("catchup_candidate", (NOW, 72))
def test_catchup_skipped_when_digest_exists(tmp_path):
store = FakeStore()
store.digests[WEEK] = object()
d, _s, _c, _st, run_fn, _ = make_daemon(tmp_path, clock=FakeClock(catchup=WEEK), store=store)
assert d.run_catchup() is False
assert run_fn.weeks == []
def test_catchup_skipped_without_candidate(tmp_path):
d, _s, _c, _st, run_fn, _ = make_daemon(tmp_path)
assert d.run_catchup() is False
assert run_fn.weeks == []
def test_catchup_failure_is_logged_and_start_continues(tmp_path):
d, scheduler, _c, _st, run_fn, deliverer = make_daemon(
tmp_path, clock=FakeClock(catchup=WEEK, next_run=NEXT_RUN), run_fn=RunRecorder(fail=True)
)
d.start() # must not raise
assert run_fn.weeks == [WEEK]
assert DIGEST_JOB_ID in scheduler.jobs
assert deliverer.errors and "catch-up" in deliverer.errors[0][0]
def test_catchup_window_comes_from_settings(tmp_path):
settings = make_settings(tmp_path, SEBRIEF_CATCHUP_WINDOW_HOURS="24")
d, _s, clock, *_ = make_daemon(tmp_path, settings=settings)
d.run_catchup()
assert clock.calls[0] == ("catchup_candidate", (NOW, 24))
# -------------------------------------------------------------------------------------- job
def test_job_runs_pipeline_and_reschedules(tmp_path):
d, scheduler, clock, _st, run_fn, deliverer = make_daemon(tmp_path)
d.schedule_next()
job = scheduler.jobs[DIGEST_JOB_ID]
clock._next_run = ("2026-W38", datetime(2026, 9, 21, 7, 0, tzinfo=KST))
job["func"](*job["args"])
assert run_fn.weeks == [NEXT_RUN[0]]
assert scheduler.jobs[DIGEST_JOB_ID]["args"] == ["2026-W38", clock._next_run[1]]
assert d.next_run == clock._next_run
assert deliverer.errors == []
def test_job_exception_notifies_and_still_reschedules(tmp_path):
d, scheduler, _clock, _st, run_fn, deliverer = make_daemon(tmp_path, run_fn=RunRecorder(fail=True))
d.schedule_next()
job = scheduler.jobs[DIGEST_JOB_ID]
job["func"](*job["args"]) # must not raise
assert run_fn.weeks == [NEXT_RUN[0]]
assert scheduler.jobs[DIGEST_JOB_ID]["trigger"].run_date == NEXT_RUN[1]
subject, detail = deliverer.errors[0]
assert "다이제스트 실패" in subject and "pipeline exploded" in detail
@pytest.mark.parametrize(("lateness_h", "expect_run"), [(0, True), (48, True), (72, False), (100, False)])
def test_late_job_honours_catchup_window(tmp_path, lateness_h, expect_run):
d, scheduler, _c, _st, run_fn, deliverer = make_daemon(tmp_path)
run_at = NOW - timedelta(hours=lateness_h)
d._digest_job(WEEK, run_at)
assert (run_fn.weeks == [WEEK]) is expect_run
assert scheduler.jobs[DIGEST_JOB_ID]["trigger"].run_date == NEXT_RUN[1] # always rescheduled
assert deliverer.errors == []
# --------------------------------------------------------------------------- heartbeat / missed
def test_heartbeat_recovers_dropped_job_inside_window(tmp_path):
now = {"t": NOW}
d, scheduler, clock, _st, run_fn, _ = make_daemon(tmp_path)
d.now_fn = lambda: now["t"]
d.schedule_next()
del scheduler.jobs[DIGEST_JOB_ID] # what APScheduler does when it drops a misfired job
now["t"] = NEXT_RUN[1] + timedelta(hours=2)
clock.catchup = NEXT_RUN[0]
d._heartbeat_job()
assert run_fn.weeks == [NEXT_RUN[0]]
assert DIGEST_JOB_ID in scheduler.jobs
def test_heartbeat_leaves_schedule_alone_when_run_is_imminent(tmp_path):
now = {"t": NOW}
d, scheduler, _c, _st, run_fn, _ = make_daemon(tmp_path)
d.now_fn = lambda: now["t"]
d.schedule_next()
for delta in (timedelta(minutes=-30), timedelta(0), timedelta(minutes=2)):
now["t"] = NEXT_RUN[1] + delta
d._heartbeat_job()
assert len(scheduler.add_calls) == 1 and run_fn.weeks == []
def test_heartbeat_does_not_run_while_digest_in_progress(tmp_path):
now = {"t": NOW}
d, scheduler, clock, _st, run_fn, _ = make_daemon(tmp_path)
d.now_fn = lambda: now["t"]
d.schedule_next()
now["t"] = NEXT_RUN[1] + timedelta(hours=1)
clock.catchup = NEXT_RUN[0]
with d._run_lock: # a digest is running on another executor thread
d._heartbeat_job()
assert run_fn.weeks == [] # no concurrent second run
assert DIGEST_JOB_ID in scheduler.jobs # but re-armed
def test_on_job_missed_queues_recovery_under_a_different_id(tmp_path):
d, scheduler, clock, _st, run_fn, _ = make_daemon(tmp_path)
d.start()
del scheduler.jobs[DIGEST_JOB_ID]
clock.catchup = NEXT_RUN[0]
d._on_job_missed(JobExecutionEvent(EVENT_JOB_MISSED, DIGEST_JOB_ID, "default", NEXT_RUN[1]))
assert scheduler.add_calls[-1]["id"] == RECOVERY_JOB_ID
recovery = scheduler.jobs[RECOVERY_JOB_ID]
assert isinstance(recovery["trigger"], DateTrigger) and recovery["misfire_grace_time"] is None
recovery["func"]()
assert run_fn.weeks == [NEXT_RUN[0]]
assert DIGEST_JOB_ID in scheduler.jobs
n_calls = len(scheduler.add_calls)
d._on_job_missed(JobExecutionEvent(EVENT_JOB_MISSED, HEARTBEAT_JOB_ID, "default", NEXT_RUN[1]))
assert len(scheduler.add_calls) == n_calls # other jobs are ignored
# --------------------------------------------------------------------------------- shutdown
def test_signal_handler_shuts_scheduler_down(tmp_path):
d, scheduler, *_ = make_daemon(tmp_path)
old_term, old_int = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT)
try:
d.install_signal_handlers()
handler = signal.getsignal(signal.SIGTERM)
assert callable(handler) and handler is not old_term
handler(signal.SIGTERM, None)
finally:
signal.signal(signal.SIGTERM, old_term)
signal.signal(signal.SIGINT, old_int)
assert scheduler.shutdown_calls == [False]
def test_signal_during_catchup_unwinds_when_scheduler_not_running(tmp_path):
class NotStarted(FakeScheduler):
def shutdown(self, wait=True):
super().shutdown(wait)
raise SchedulerNotRunningError
d, *_ = make_daemon(tmp_path)
d.scheduler = NotStarted()
old_term, old_int = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT)
try:
d.install_signal_handlers()
handler = signal.getsignal(signal.SIGTERM)
with pytest.raises(KeyboardInterrupt):
handler(signal.SIGTERM, None)
finally:
signal.signal(signal.SIGTERM, old_term)
signal.signal(signal.SIGINT, old_int)
assert d.stop_requested is True
def test_shutdown_during_catchup_prevents_scheduling(tmp_path):
class NotStarted(FakeScheduler):
def shutdown(self, wait=True):
super().shutdown(wait)
raise SchedulerNotRunningError
holder: dict = {}
def run_fn(week):
holder["d"].shutdown()
d, *_ = make_daemon(tmp_path, clock=FakeClock(catchup=WEEK, next_run=NEXT_RUN), run_fn=run_fn)
d.scheduler = NotStarted()
holder["d"] = d
d.start()
assert d.stop_requested is True
assert d.scheduler.jobs == {} # nothing scheduled; run_daemon skips scheduler.start()
# ------------------------------------------------------------------------------- run_daemon
def test_run_daemon_wires_real_parts_and_blocks_on_scheduler(monkeypatch, tmp_path):
started = {}
fake_store = FakeStore()
class FakeBlocking(FakeScheduler):
def __init__(self, timezone=None):
super().__init__()
started["timezone"] = timezone
def start(self, paused=False):
started["started"] = True
raise KeyboardInterrupt # simulate Ctrl-C ending the blocking loop
monkeypatch.setattr(daemon_mod, "BlockingScheduler", FakeBlocking)
monkeypatch.setattr(daemon_mod, "WeeklyClock", lambda wd, t: FakeClock(next_run=NEXT_RUN))
monkeypatch.setattr(daemon_mod, "Store", lambda path: fake_store)
monkeypatch.setattr(daemon_mod.signal, "signal", lambda *a, **k: None)
daemon_mod.run_daemon(make_settings(tmp_path))
assert started["started"] is True
assert str(started["timezone"]) == "Asia/Seoul"
assert fake_store.closed is True
def test_run_lock_serialises_catchup_and_job(tmp_path):
seen = {}
d, *_ = make_daemon(tmp_path)
def run_fn(week):
seen["locked"] = d._run_lock.locked()
d.run_fn = run_fn
d._digest_job(WEEK, NOW)
assert seen["locked"] is True
def test_notify_error_failures_are_swallowed(tmp_path):
class Exploding(FakeDeliverer):
def notify_error(self, subject, detail):
raise RuntimeError("channel down")
d, *_ = make_daemon(tmp_path, run_fn=RunRecorder(fail=True), deliverer=Exploding("x"))
d._digest_job(WEEK) # must not raise
d.deliverers_fn = lambda s: (_ for _ in ()).throw(RuntimeError("cannot build"))
d._digest_job(WEEK) # must not raise either
+62
View File
@@ -0,0 +1,62 @@
"""File deliverer: archive writes, receipts, errors.log alerting."""
from __future__ import annotations
import json
import pytest
from _fakes import make_payload
from sebrief.config import Settings
from sebrief.deliver import build_deliverers
from sebrief.deliver.base import DeliveryError
from sebrief.deliver.file import FileDeliverer
from sebrief.models import DigestResult
def make_result() -> DigestResult:
return DigestResult(payload=make_payload(), digest=None, markdown="# 다이제스트\n", model_used="m")
def test_deliver_writes_md_and_json_keyed_by_week(tmp_path):
deliverer = FileDeliverer(tmp_path / "reports")
receipt = deliverer.deliver(make_result())
md = tmp_path / "reports" / "2026-W36.md"
js = tmp_path / "reports" / "2026-W36.json"
assert receipt == str(md)
assert md.read_text(encoding="utf-8") == "# 다이제스트\n"
data = json.loads(js.read_text(encoding="utf-8"))
assert data["payload"]["week"] == "2026-W36"
assert data["markdown"].startswith("# 다이제스트")
def test_deliver_raises_delivery_error_on_os_error(tmp_path):
blocker = tmp_path / "reports"
blocker.write_text("file, not a dir")
deliverer = FileDeliverer(blocker)
with pytest.raises(DeliveryError):
deliverer.deliver(make_result())
def test_notify_error_appends_flattened_line(tmp_path):
deliverer = FileDeliverer(tmp_path / "reports")
deliverer.notify_error("제목", "줄1\n줄2\n")
deliverer.notify_error("두번째", "detail")
lines = (tmp_path / "reports" / "errors.log").read_text(encoding="utf-8").splitlines()
assert len(lines) == 2
ts, subject, detail = lines[0].split("\t")
assert subject == "제목" and detail == "줄1 | 줄2" and ts
assert lines[1].split("\t")[1] == "두번째"
def test_notify_error_never_raises(tmp_path):
blocker = tmp_path / "reports"
blocker.write_text("file, not a dir")
FileDeliverer(blocker).notify_error("s", "d") # must not raise
def test_build_deliverers_is_file_only(tmp_path):
settings = Settings(_env_file=None, SEBRIEF_REPORTS_DIR=str(tmp_path / "reports"))
deliverers = build_deliverers(settings)
assert [d.name for d in deliverers] == ["file"]
+201
View File
@@ -0,0 +1,201 @@
"""LLM layer: JSON extraction/coercion, prompt building, provider fallback and repair retry."""
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from _fakes import make_digest, make_payload
from sebrief.config import Settings
from sebrief.llm.base import LLMError
from sebrief.llm.minimax import (
MiniMaxProvider,
coerce_model_data,
digest_json_schema,
extract_json_text,
parse_digest,
)
from sebrief.llm.prompts import build_user_message, build_verify_message, format_issues
from sebrief.models import Digest, VerificationIssue
VALID_DIGEST_JSON = make_digest().model_dump_json()
# ------------------------------------------------------------------------ extraction/parsing
def test_extract_json_text_plain_and_fenced():
assert extract_json_text('{"a": 1}') == '{"a": 1}'
assert extract_json_text('```json\n{"a": 1}\n```') == '{"a": 1}'
def test_extract_json_text_strips_think_blocks():
raw = f"<think>reasoning {{nested}} </think>{VALID_DIGEST_JSON}"
assert json.loads(extract_json_text(raw)) == json.loads(VALID_DIGEST_JSON)
def test_extract_json_text_unterminated_think_falls_back_to_first_object():
raw = f"<think>never closed... {VALID_DIGEST_JSON}"
assert json.loads(extract_json_text(raw))["overview"].startswith("이번 주")
def test_extract_json_text_raises_without_object():
with pytest.raises(ValueError):
extract_json_text("no json here")
def test_parse_digest_valid():
digest = parse_digest(VALID_DIGEST_JSON)
assert isinstance(digest, Digest)
assert digest.highlights[0].doi == "10.1145/1234567"
def test_coerce_string_list_fields():
data = json.loads(VALID_DIGEST_JSON)
data["data_gaps"] = "누락 1. 누락 2."
coerced, fields = coerce_model_data(data, Digest)
assert fields == ["data_gaps"]
assert coerced["data_gaps"] == ["누락 1.", "누락 2."]
# nested object lists are left for pydantic to validate
digest = Digest.model_validate(coerced)
assert len(digest.data_gaps) == 2
def test_digest_json_schema_is_closed_and_untitled():
schema = digest_json_schema()
assert schema["additionalProperties"] is False
assert "title" not in schema
assert "overview" in schema["properties"]
# ------------------------------------------------------------------------------- prompts
def test_build_user_message_contains_papers_and_gaps():
payload = make_payload(gaps=["rss: down"])
msg = build_user_message(payload)
assert "## 대상 주차" in msg and "2026-W36" in msg
assert "DOI: 10.1145/1234567" in msg
assert "Test-Driven Digest Generation" in msg
assert "- rss: down" in msg
def test_build_user_message_truncates_long_abstracts():
payload = make_payload(papers=[make_payload().papers[0].model_copy(update={"abstract": "x" * 5000})])
msg = build_user_message(payload)
assert "x" * 1800 not in msg and "…" in msg
def test_build_verify_message_appends_draft():
payload = make_payload()
msg = build_verify_message(payload, make_digest())
assert msg.startswith(build_user_message(payload))
assert "## 검증 대상 다이제스트 초안 (JSON)" in msg
def test_format_issues():
issues = [VerificationIssue(claim="c", problem="p", severity="major")]
text = format_issues(issues)
assert "[major]" in text and "서술: c" in text and "문제: p" in text
assert format_issues([]) == "(없음)"
# ------------------------------------------------------------------------------- provider
class FakeClient:
"""chat.completions.create stub answering from a queue of contents (or exceptions)."""
def __init__(self, replies: list):
self.replies = list(replies)
self.calls: list[dict] = []
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
def _create(self, **kwargs):
self.calls.append(kwargs)
reply = self.replies.pop(0)
if isinstance(reply, Exception):
raise reply
finish = "length" if reply == "<TRUNCATED>" else "stop"
message = SimpleNamespace(content=None if reply == "<EMPTY>" else reply)
choice = SimpleNamespace(message=message, finish_reason=finish)
return SimpleNamespace(choices=[choice], model=kwargs["model"], usage=None)
def make_provider(replies: list, models: str = "m1", fallbacks: str = "m2") -> tuple[MiniMaxProvider, FakeClient]:
settings = Settings(
_env_file=None,
MINIMAX_API_KEY="sk-test-key",
MINIMAX_MODEL=models,
MINIMAX_FALLBACK_MODELS=fallbacks,
)
client = FakeClient(replies)
return MiniMaxProvider(settings, client=client), client
def test_summarize_happy_path():
provider, client = make_provider([VALID_DIGEST_JSON])
digest, model = provider.summarize(make_payload())
assert model == "m1" and digest.overview
assert client.calls[0]["response_format"]["json_schema"]["name"] == "digest"
def test_invalid_reply_triggers_repair_then_fallback_model():
# m1: garbage twice (initial + repair) -> m2 succeeds
provider, client = make_provider(["not json", "{\"still\": \"wrong\"}", VALID_DIGEST_JSON])
_digest, model = provider.summarize(make_payload())
assert model == "m2"
assert len(client.calls) == 3
repair_call = client.calls[1]
assert any("JSON 스키마 검증에 실패" in m["content"] for m in repair_call["messages"] if m["role"] == "user")
def test_truncated_and_empty_replies_move_to_next_model():
provider, _ = make_provider(["<TRUNCATED>", VALID_DIGEST_JSON])
_, model = provider.summarize(make_payload())
assert model == "m2"
provider, _ = make_provider(["<EMPTY>", VALID_DIGEST_JSON])
_, model = provider.summarize(make_payload())
assert model == "m2"
def test_all_models_failed_raises_llm_error():
provider, _ = make_provider(["nope", "nope", "nope", "nope"]) # 2 models x (initial + repair)
with pytest.raises(LLMError, match="all MiniMax models failed"):
provider.summarize(make_payload())
def test_summarize_papers_uses_batch_schema_and_prompt():
reply = (
'{"summaries": [{"doi": "10.1145/1234567", "tldr": "한 줄.", "problem": "p", '
'"approach": "a", "findings": "f", "implication": "i"}]}'
)
provider, client = make_provider([reply])
items, model = provider.summarize_papers(make_payload().papers)
assert model == "m1" and len(items) == 1 and items[0].doi == "10.1145/1234567"
call = client.calls[0]
assert call["response_format"]["json_schema"]["name"] == "paper_summaries"
assert call["messages"][0]["content"].startswith("당신은 소프트웨어공학(SE) 논문 요약자")
assert "DOI: 10.1145/1234567" in call["messages"][1]["content"]
def test_verify_and_revise_use_expected_message_shapes():
verification_json = '{"ok": false, "issues": [{"claim": "c", "problem": "p", "severity": "major"}]}'
provider, client = make_provider([verification_json])
verification, _ = provider.verify(make_payload(), make_digest())
assert verification.has_major
assert client.calls[0]["messages"][0]["content"].startswith("당신은 소프트웨어공학 주간 다이제스트의 팩트체커")
provider, client = make_provider([VALID_DIGEST_JSON])
issues = [VerificationIssue(claim="c", problem="p", severity="major")]
provider.revise(make_payload(), make_digest(), issues)
roles = [m["role"] for m in client.calls[0]["messages"]]
assert roles == ["system", "user", "assistant", "user"]
assert "검증 이슈" in client.calls[0]["messages"][-1]["content"]
def test_provider_requires_api_key_and_models():
with pytest.raises(LLMError, match="MINIMAX_API_KEY"):
MiniMaxProvider(Settings(_env_file=None, MINIMAX_API_KEY=""))
with pytest.raises(LLMError, match="no MiniMax model"):
MiniMaxProvider(
Settings(_env_file=None, MINIMAX_API_KEY="sk-x", MINIMAX_MODEL="", MINIMAX_FALLBACK_MODELS=""),
client=object(),
)
+72
View File
@@ -0,0 +1,72 @@
"""Secret masking and logging handler setup."""
from __future__ import annotations
import logging
from sebrief.config import Settings
from sebrief.logging_setup import (
LOG_FILE_NAME,
mask_secrets,
register_secret_values,
setup_logging,
)
def teardown_function():
register_secret_values() # clear the registry between tests
def test_mask_sk_keys_and_url_params():
assert mask_secrets("key sk-abcdef123456 leaked") == "key *** leaked"
assert mask_secrets("GET https://x/api?api_key=deadbeef&x=1") == "GET https://x/api?api_key=***&x=1"
assert mask_secrets("token=verysecretvalue123") == "token=verysecretvalue123" # no ?/& prefix
def test_registered_values_masked_verbatim():
register_secret_values("my-odd-shaped-secret")
assert mask_secrets("value my-odd-shaped-secret here") == "value *** here"
register_secret_values() # cleared
assert "my-odd-shaped-secret" in mask_secrets("value my-odd-shaped-secret here")
def test_short_values_not_registered():
register_secret_values("short")
assert mask_secrets("short") == "short"
def test_setup_logging_masks_in_file_and_is_idempotent(tmp_path):
settings = Settings(
_env_file=None,
MINIMAX_API_KEY="sk-verysecretkey12345",
SEBRIEF_LOG_DIR=str(tmp_path / "logs"),
)
setup_logging(settings)
setup_logging(settings) # handlers replaced, not duplicated
root = logging.getLogger()
ours = [h for h in root.handlers if getattr(h, "_sebrief_handler", False)]
assert len(ours) == 2 # console + file
logging.getLogger("sebrief.test").info("key=%s", "sk-verysecretkey12345")
for handler in ours:
handler.flush()
text = (tmp_path / "logs" / LOG_FILE_NAME).read_text(encoding="utf-8")
assert "sk-verysecretkey12345" not in text
assert "***" in text
for handler in ours: # clean up for other tests
root.removeHandler(handler)
handler.close()
def test_setup_logging_survives_unwritable_log_dir(tmp_path):
blocker = tmp_path / "logs"
blocker.write_text("file, not a dir")
settings = Settings(_env_file=None, SEBRIEF_LOG_DIR=str(blocker))
setup_logging(settings) # must not raise
root = logging.getLogger()
ours = [h for h in root.handlers if getattr(h, "_sebrief_handler", False)]
assert len(ours) == 1 # console only
for handler in ours:
root.removeHandler(handler)
handler.close()
+205
View File
@@ -0,0 +1,205 @@
"""OpenAlex collector: abstract reconstruction, work mapping, filters, pagination, failure modes."""
from __future__ import annotations
from datetime import date
import pytest
from sebrief.collectors import openalex
from sebrief.collectors.openalex import (
fetch_openalex_papers,
normalize_doi,
paper_from_work,
reconstruct_abstract,
)
from sebrief.config import Settings
SETTINGS = Settings(_env_file=None)
START, END = date(2026, 8, 24), date(2026, 9, 6)
# ------------------------------------------------------------------------------- pure parts
@pytest.mark.parametrize(
("raw", "expected"),
[
("https://doi.org/10.1145/1234567", "10.1145/1234567"),
("http://dx.doi.org/10.1109/TSE.2026.1", "10.1109/tse.2026.1"),
("doi:10.1000/ABC", "10.1000/abc"),
("10.1000/abc", "10.1000/abc"),
("https://example.org/paper", None),
("", None),
(None, None),
],
)
def test_normalize_doi(raw, expected):
assert normalize_doi(raw) == expected
def test_reconstruct_abstract():
inv = {"study": [2], "We": [0], "digests.": [3], "weekly": [1]}
assert reconstruct_abstract(inv) == "We weekly study digests."
assert reconstruct_abstract(None) == ""
assert reconstruct_abstract({}) == ""
def make_work(**overrides) -> dict:
work = {
"doi": "https://doi.org/10.1145/1234567",
"title": "Test-Driven Digest Generation",
"abstract_inverted_index": {"We": [0], "test.": [1]},
"publication_date": "2026-09-02",
"authorships": [
{"author": {"display_name": "Alice Kim"}},
{"author": {"display_name": "Bob Lee"}},
],
"primary_location": {
"source": {
"display_name": "ACM Transactions on Software Engineering and Methodology",
"issn_l": "1049-331X",
"issn": ["1049-331X", "1557-7392"],
"host_organization_name": "Association for Computing Machinery",
}
},
"best_oa_location": {
"landing_page_url": "https://dl.acm.org/doi/10.1145/1234567",
"pdf_url": "https://dl.acm.org/doi/pdf/10.1145/1234567",
},
"open_access": {"oa_url": "https://dl.acm.org/doi/10.1145/1234567"},
"topics": [{"display_name": "Software Engineering Research"}],
}
work.update(overrides)
return work
def test_paper_from_work_maps_all_fields():
paper = paper_from_work(make_work())
assert paper is not None
assert paper.doi == "10.1145/1234567"
assert paper.title == "Test-Driven Digest Generation"
assert paper.abstract == "We test."
assert paper.authors == ["Alice Kim", "Bob Lee"]
assert paper.venue.startswith("ACM Transactions")
assert paper.published_date == date(2026, 9, 2)
assert paper.oa_url == "https://dl.acm.org/doi/10.1145/1234567"
assert paper.pdf_url == "https://dl.acm.org/doi/pdf/10.1145/1234567"
assert paper.topics == ["Software Engineering Research"]
def test_paper_from_work_rejects_missing_doi_or_title():
assert paper_from_work(make_work(doi=None)) is None
assert paper_from_work(make_work(doi="https://example.org/x")) is None
assert paper_from_work(make_work(title="", display_name="")) is None
def test_paper_from_work_tolerates_sparse_work():
paper = paper_from_work({"doi": "https://doi.org/10.1/x", "title": "T"})
assert paper is not None
assert paper.abstract == "" and paper.authors == [] and paper.published_date is None
# -------------------------------------------------------------------------------- fetching
class FakeResponse:
def __init__(self, body: dict, status: int = 200):
self._body = body
self.status_code = status
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
def json(self) -> dict:
return self._body
def install_fetch(monkeypatch, pages_by_query: dict[str, list[dict]]):
"""Route requests.get by whether the filter string contains the subfield filter (mega query)."""
calls: list[dict] = []
def fake_get(url, params=None, timeout=None, headers=None):
calls.append({"url": url, "params": params})
key = "mega" if "topics.subfield.id" in params["filter"] else "whitelist"
pages = pages_by_query.get(key, [])
idx = sum(1 for c in calls[:-1] if ("topics.subfield.id" in c["params"]["filter"]) == (key == "mega"))
if isinstance(pages, Exception):
raise pages
body = pages[idx] if idx < len(pages) else {"results": [], "meta": {"next_cursor": None}}
return FakeResponse(body)
monkeypatch.setattr(openalex.requests, "get", fake_get)
return calls
def test_fetch_merges_queries_applies_filters_and_dedups(monkeypatch):
mdpi = make_work(
doi="https://doi.org/10.3390/xyz",
primary_location={"source": {"display_name": "Electronics", "issn_l": "2079-9292", "issn": [], "host_organization_name": "MDPI AG"}},
)
off_topic = make_work(
doi="https://doi.org/10.1109/access.1",
title="Optimal Beamforming for 6G Networks",
abstract_inverted_index={"MIMO": [0]},
topics=[{"display_name": "Wireless Communication"}],
)
on_topic = make_work(
doi="https://doi.org/10.1109/access.2",
title="LLM-based Code Review at Scale",
topics=[{"display_name": "Software Engineering Research"}],
)
dup = make_work() # same DOI as the whitelist hit
pages = {
"whitelist": [{"results": [make_work(), mdpi], "meta": {"next_cursor": None}}],
"mega": [{"results": [off_topic, on_topic, dup], "meta": {"next_cursor": None}}],
}
calls = install_fetch(monkeypatch, pages)
papers = fetch_openalex_papers(START, END, SETTINGS)
assert [p.doi for p in papers] == ["10.1145/1234567", "10.1109/access.2"]
filters = [c["params"]["filter"] for c in calls]
assert any("primary_location.source.issn:" in f and "is_oa:true" in f for f in filters)
assert any(f"topics.subfield.id:{SETTINGS.se_subfield_id}" in f for f in filters)
assert all(f"from_publication_date:{START}" in f and f"to_publication_date:{END}" in f for f in filters)
def test_fetch_follows_cursor_pagination(monkeypatch):
page1 = {"results": [make_work()], "meta": {"next_cursor": "c2"}}
page2 = {"results": [make_work(doi="https://doi.org/10.1/second")], "meta": {"next_cursor": None}}
calls = install_fetch(monkeypatch, {"whitelist": [page1, page2], "mega": []})
papers = fetch_openalex_papers(START, END, SETTINGS)
assert {p.doi for p in papers} == {"10.1145/1234567", "10.1/second"}
whitelist_calls = [c for c in calls if "subfield" not in c["params"]["filter"]]
assert [c["params"]["cursor"] for c in whitelist_calls] == ["*", "c2"]
def test_one_failed_query_keeps_the_other(monkeypatch):
def fake_get(url, params=None, timeout=None, headers=None):
if "topics.subfield.id" in params["filter"]:
raise RuntimeError("mega query down")
return FakeResponse({"results": [make_work()], "meta": {"next_cursor": None}})
monkeypatch.setattr(openalex.requests, "get", fake_get)
papers = fetch_openalex_papers(START, END, SETTINGS)
assert [p.doi for p in papers] == ["10.1145/1234567"]
def test_all_queries_failed_raises(monkeypatch):
def fake_get(*a, **k):
raise RuntimeError("openalex down")
monkeypatch.setattr(openalex.requests, "get", fake_get)
with pytest.raises(RuntimeError, match="all openalex queries failed"):
fetch_openalex_papers(START, END, SETTINGS)
def test_mailto_param_only_when_configured(monkeypatch):
calls = install_fetch(monkeypatch, {"whitelist": [], "mega": []})
fetch_openalex_papers(START, END, SETTINGS)
assert all("mailto" not in c["params"] for c in calls)
calls.clear()
settings = Settings(_env_file=None, SEBRIEF_OPENALEX_MAILTO="ops@example.org")
fetch_openalex_papers(START, END, settings)
assert all(c["params"]["mailto"] == "ops@example.org" for c in calls)
+212
View File
@@ -0,0 +1,212 @@
"""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")),
}
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_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_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_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 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"])
+108
View File
@@ -0,0 +1,108 @@
"""Markdown rendering: sections, DOI link resolution, degraded and empty-week modes."""
from __future__ import annotations
from _fakes import make_digest, make_paper, make_payload, make_summary
from sebrief.render import render_markdown
def test_full_digest_renders_all_sections_with_links():
payload = make_payload()
md = render_markdown(payload, make_digest(), "fake-model")
assert md.startswith("# 📚 SE 주간 브리핑 — 2026-W36 (2026-08-31 ~ 2026-09-06)")
assert "수집 논문 1편" in md
for section in ("## 이번 주 흐름", "## 하이라이트", "## 주제별 훑어보기", "## 읽어볼 논문", "## 이번 주 수집 논문"):
assert section in md
# DOI resolves through the payload's oa_url, not a bare doi.org link
assert "[Test-Driven Digest Generation](https://dl.acm.org/doi/10.1145/1234567)" in md
assert "- **문제**:" in md and "- **시사점**:" in md
assert "축소 모드" not in md
def test_doi_mixup_is_rescued_by_title_match():
# The LLM shuffled DOIs but kept the title: the link must follow the (visible) title.
other = make_paper("10.1/other", title="A Different Paper", oa_url="https://x.example/other")
payload = make_payload(papers=[make_paper(), other])
digest = make_digest(doi="10.1/other") # wrong DOI, correct title "Test-Driven Digest Generation"
md = render_markdown(payload, digest, None)
assert "[Test-Driven Digest Generation](https://dl.acm.org/doi/10.1145/1234567)" in md
assert "[Test-Driven Digest Generation](https://x.example/other)" not in md
def test_unknown_doi_and_title_render_plain_text():
digest = make_digest(doi="10.9999/unknown")
for h in digest.highlights:
h.title = "Invented Paper Title"
md = render_markdown(make_payload(), digest, None)
assert "### Invented Paper Title" in md # highlight title without a link
assert "[Invented Paper Title]" not in md
def test_title_collision_falls_back_to_doi():
twin_a = make_paper("10.1/a", title="Same Title", oa_url="https://x.example/a")
twin_b = make_paper("10.1/b", title="Same Title", oa_url="https://x.example/b")
payload = make_payload(papers=[twin_a, twin_b])
digest = make_digest(doi="10.1/b")
for h in digest.highlights:
h.title = "Same Title"
md = render_markdown(payload, digest, None)
assert "[Same Title](https://x.example/b)" in md # ambiguous title -> trust the DOI
def test_doi_without_oa_url_falls_back_to_doi_org():
paper = make_paper(oa_url=None)
md = render_markdown(make_payload(papers=[paper]), make_digest(), None)
assert "(https://doi.org/10.1145/1234567)" in md
def test_degraded_mode_paper_table_only():
payload = make_payload(gaps=["openalex: down"])
md = render_markdown(payload, None, None)
assert "🧩 축소 모드(LLM 요약 없음)" in md
assert "## 이번 주 수집 논문" in md
assert "| 제목 | 저널 | 발행일 |" in md
assert "## 하이라이트" not in md
assert "- openalex: down" in md
def test_empty_week():
md = render_markdown(make_payload(papers=[]), None, None)
assert "이번 주 수집된 OA 논문이 없습니다." in md
assert "축소 모드" not in md
assert "## 이번 주 수집 논문" not in md
def test_paper_summary_section_renders_cards():
with_summary = make_paper(summary=make_summary(tldr="한 줄 요약입니다."))
without = make_paper("10.1/nosummary", title="No Summary Yet")
md = render_markdown(make_payload(papers=[with_summary, without]), make_digest(), None)
assert "## 논문별 요약" in md
assert "**한 줄 요약입니다.**" in md
assert "- **문제**: 주간 다이제스트 생성이 느립니다." in md
# summary-less papers appear only in the table, not as a summary card
section = md.split("## 논문별 요약")[1].split("## 이번 주 수집 논문")[0]
assert "No Summary Yet" not in section
def test_paper_summaries_render_even_without_digest():
md = render_markdown(make_payload(papers=[make_paper(summary=make_summary())]), None, None)
assert "## 논문별 요약" in md and "축소 모드" in md
def test_no_summary_section_when_no_paper_has_one():
md = render_markdown(make_payload(), make_digest(), None)
assert "## 논문별 요약" not in md
def test_table_cells_and_link_text_are_escaped():
paper = make_paper(title="Pipes | and [brackets]", venue="Ven|ue")
md = render_markdown(make_payload(papers=[paper]), None, None)
assert "Pipes \\| and (brackets)" in md
assert "Ven\\|ue" in md
def test_url_with_parentheses_is_encoded():
paper = make_paper(oa_url="https://example.org/paper(1)")
md = render_markdown(make_payload(papers=[paper]), None, None)
assert "https://example.org/paper%281%29" in md
+106
View File
@@ -0,0 +1,106 @@
"""RSS collector: feed spec parsing, DOI extraction, date filtering, failure isolation."""
from __future__ import annotations
from datetime import date
import pytest
from sebrief.collectors import rss
from sebrief.collectors.rss import extract_doi, fetch_rss_papers, parse_feed_specs, strip_html
START, END = date(2026, 8, 31), date(2026, 9, 6)
FEED_XML = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>e-Informatica SEJ</title>
<item>
<title>Mutation Testing &amp; Coverage</title>
<link>https://doi.org/10.37190/e-inf/2026.1</link>
<description>&lt;p&gt;We study &lt;b&gt;mutation&lt;/b&gt; testing.&lt;/p&gt;</description>
<pubDate>Wed, 02 Sep 2026 08:00:00 GMT</pubDate>
</item>
<item>
<title>Too Old Paper</title>
<link>https://doi.org/10.37190/e-inf/2025.9</link>
<pubDate>Mon, 03 Aug 2026 08:00:00 GMT</pubDate>
</item>
<item>
<title>No DOI Entry</title>
<link>https://example.org/announcement</link>
<pubDate>Thu, 03 Sep 2026 08:00:00 GMT</pubDate>
</item>
</channel></rss>
"""
class FakeResponse:
def __init__(self, content: bytes):
self.content = content
def raise_for_status(self) -> None:
pass
def test_parse_feed_specs():
feeds, invalid = parse_feed_specs(
["e-Inf|https://example.org/rss", "broken", "|https://x.example", "Name|ftp://bad"]
)
assert feeds == [("e-Inf", "https://example.org/rss")]
assert invalid == ["broken", "|https://x.example", "Name|ftp://bad"]
def test_strip_html():
assert strip_html("<p>We study <b>mutation</b>&nbsp;testing.</p>") == "We study mutation testing."
assert strip_html(None) == ""
assert strip_html("x" * 3000, max_chars=10).endswith("…")
@pytest.mark.parametrize(
("entry", "expected"),
[
({"link": "https://doi.org/10.37190/e-inf/2026.1"}, "10.37190/e-inf/2026.1"),
({"link": "https://x.org/a", "summary": "see doi 10.1000/xyz."}, "10.1000/xyz"),
({"id": "urn:doi:10.5555/abc"}, "10.5555/abc"),
({"link": "https://example.org/none"}, None),
],
)
def test_extract_doi(entry, expected):
assert extract_doi(entry) == expected
def test_fetch_maps_entries_and_filters_by_date(monkeypatch):
monkeypatch.setattr(rss.requests, "get", lambda *a, **k: FakeResponse(FEED_XML.encode()))
papers = fetch_rss_papers([("e-Inf", "https://example.org/rss")], START, END)
assert [p.doi for p in papers] == ["10.37190/e-inf/2026.1"]
paper = papers[0]
assert paper.title == "Mutation Testing & Coverage"
assert paper.abstract == "We study mutation testing."
assert paper.venue == "e-Inf" and paper.source == "rss:e-Inf"
assert paper.published_date == date(2026, 9, 2)
def test_one_feed_down_keeps_the_other(monkeypatch):
def fake_get(url, timeout=None, headers=None):
if "down" in url:
raise RuntimeError("boom")
return FakeResponse(FEED_XML.encode())
monkeypatch.setattr(rss.requests, "get", fake_get)
papers = fetch_rss_papers([("A", "https://down.example/rss"), ("B", "https://ok.example/rss")], START, END)
assert len(papers) == 1
def test_all_feeds_down_raises(monkeypatch):
def fake_get(*a, **k):
raise RuntimeError("boom")
monkeypatch.setattr(rss.requests, "get", fake_get)
with pytest.raises(RuntimeError, match="all rss feeds failed"):
fetch_rss_papers([("A", "https://a.example/rss")], START, END)
def test_no_feeds_is_a_noop():
assert fetch_rss_papers([], START, END) == []
+37
View File
@@ -0,0 +1,37 @@
"""Whitelist/blacklist and the SE keyword double filter."""
from __future__ import annotations
from sebrief.sources import SOURCES, is_blacklisted, issns_for, looks_like_se
def test_issns_for_splits_plain_and_mega():
plain = issns_for(se_filter=False)
mega = issns_for(se_filter=True)
assert "1049-331X" in plain # TOSEM
assert "2169-3536" in mega # IEEE Access
assert set(plain).isdisjoint(mega)
assert len(plain) + len(mega) == sum(len(s.issns) for s in SOURCES)
def test_mdpi_blocked_by_issn_and_publisher():
assert is_blacklisted(["2079-9292"], None) is True # Electronics (MDPI)
assert is_blacklisted([], "MDPI AG") is True
assert is_blacklisted([], "MDPI") is True
assert is_blacklisted(["1049-331X"], "Association for Computing Machinery") is False
def test_extra_blacklist_issns():
assert is_blacklisted(["9999-0001"], None) is False
assert is_blacklisted(["9999-0001"], None, extra_issns=["9999-0001"]) is True
def test_looks_like_se_by_keyword_title_and_abstract():
assert looks_like_se("A Study on Automated Program Repair", "", []) is True
assert looks_like_se("Deep Models", "we improve defect prediction accuracy", []) is True
assert looks_like_se("Optimal Beamforming for 6G Networks", "channel estimation and MIMO", []) is False
def test_looks_like_se_by_topic_name():
assert looks_like_se("Untitled", "", ["Software Reliability and Analysis"]) is True
assert looks_like_se("Untitled", "", ["Wireless Communication"]) is False
+139
View File
@@ -0,0 +1,139 @@
"""SQLite store: paper lifecycle (upsert/freeze/rollover), digest idempotency, deliveries."""
from __future__ import annotations
from datetime import date
from _fakes import WEEK, make_paper, make_payload, make_summary
from sebrief.models import DigestResult
from sebrief.store import Store
def make_store(tmp_path) -> Store:
return Store(tmp_path / "data" / "test.sqlite3")
def make_result(papers=None, degraded=False) -> DigestResult:
payload = make_payload(papers=papers)
return DigestResult(
payload=payload, digest=None, markdown="# md", model_used="m", degraded=degraded
)
# ----------------------------------------------------------------------------------- papers
def test_upsert_inserts_and_counts_new(tmp_path):
with make_store(tmp_path) as store:
p1, p2 = make_paper("10.1/a"), make_paper("10.1/b")
assert store.upsert_papers([p1, p2]) == 2
assert store.upsert_papers([p1]) == 0 # re-collection is not new
assert store.paper_count() == 2
def test_upsert_refreshes_undigested_metadata(tmp_path):
with make_store(tmp_path) as store:
store.upsert_papers([make_paper("10.1/a", abstract="")])
store.upsert_papers([make_paper("10.1/a", abstract="now with abstract")])
[paper] = store.pending_papers(WEEK)
assert paper.abstract == "now with abstract"
def test_digested_papers_are_frozen_and_skipped(tmp_path):
with make_store(tmp_path) as store:
store.upsert_papers([make_paper("10.1/a", title="original")])
store.mark_digested(["10.1/a"], WEEK)
store.upsert_papers([make_paper("10.1/a", title="rewritten")])
# not pending for a later week, and metadata unchanged
assert store.pending_papers("2026-W37") == []
[paper] = store.pending_papers(WEEK) # --force re-run of the same week still sees it
assert paper.title == "original"
def test_pending_includes_undigested_leftovers_and_orders_by_date(tmp_path):
with make_store(tmp_path) as store:
old = make_paper("10.1/old", published_date=date(2026, 8, 20)) # last week's late arrival
new = make_paper("10.1/new", published_date=date(2026, 9, 2))
undated = make_paper("10.1/undated", published_date=None)
store.upsert_papers([new, undated, old])
dois = [p.doi for p in store.pending_papers(WEEK)]
assert dois == ["10.1/old", "10.1/new", "10.1/undated"] # dated first (ascending), undated last
def test_paper_roundtrip_preserves_fields(tmp_path):
with make_store(tmp_path) as store:
original = make_paper("10.1/a", authors=["김철수", "Bob"], topics=["Testing", "LLM"])
store.upsert_papers([original])
[loaded] = store.pending_papers(WEEK)
assert loaded == original
# ---------------------------------------------------------------------------- paper summaries
def test_paper_summaries_roundtrip_and_survive_metadata_refresh(tmp_path):
with make_store(tmp_path) as store:
store.upsert_papers([make_paper("10.1/a")])
summary = make_summary(tldr="한 줄.")
store.save_paper_summaries({"10.1/a": summary})
[paper] = store.pending_papers(WEEK)
assert paper.summary == summary
# a later re-collection refreshes metadata but must not clear the summary
store.upsert_papers([make_paper("10.1/a", abstract="updated")])
[paper] = store.pending_papers(WEEK)
assert paper.abstract == "updated" and paper.summary == summary
def test_paper_summaries_allowed_on_frozen_papers(tmp_path):
with make_store(tmp_path) as store:
store.upsert_papers([make_paper("10.1/a")])
store.mark_digested(["10.1/a"], WEEK)
store.save_paper_summaries({"10.1/a": make_summary()})
[paper] = store.pending_papers(WEEK) # digest_week = WEEK: visible on --force re-run
assert paper.summary is not None
def test_migration_adds_summary_column_to_old_db(tmp_path):
import sqlite3
db = tmp_path / "data" / "old.sqlite3"
db.parent.mkdir(parents=True)
conn = sqlite3.connect(db)
conn.executescript(
"""
CREATE TABLE papers (
doi TEXT PRIMARY KEY, title TEXT NOT NULL, abstract TEXT NOT NULL DEFAULT '',
authors TEXT NOT NULL DEFAULT '[]', venue TEXT NOT NULL DEFAULT '',
published_date TEXT, oa_url TEXT, pdf_url TEXT, topics TEXT NOT NULL DEFAULT '[]',
source TEXT NOT NULL DEFAULT '', collected_at TEXT, digest_week TEXT
);
INSERT INTO papers (doi, title) VALUES ('10.1/legacy', 'Legacy Paper');
"""
)
conn.commit()
conn.close()
with Store(db) as store:
[paper] = store.pending_papers(WEEK)
assert paper.doi == "10.1/legacy" and paper.summary is None
store.save_paper_summaries({"10.1/legacy": make_summary()})
[paper] = store.pending_papers(WEEK)
assert paper.summary is not None
# ---------------------------------------------------------------------------------- digests
def test_digest_idempotency_and_upsert(tmp_path):
with make_store(tmp_path) as store:
assert store.has_digest(WEEK) is False
store.save_digest(make_result())
assert store.has_digest(WEEK) is True
store.save_digest(make_result(degraded=True)) # --force resend overwrites
row = store._conn.execute("SELECT degraded FROM digests WHERE week = ?", (WEEK,)).fetchone()
assert row["degraded"] == 1
def test_deliveries(tmp_path):
with make_store(tmp_path) as store:
store.save_digest(make_result())
store.mark_sent(WEEK, "file", "/tmp/2026-W36.md")
store.mark_sent(WEEK, "file", "/tmp/2026-W36.md (resend)")
rows = store.deliveries_for(WEEK)
assert [r["channel"] for r in rows] == ["file", "file"]
assert rows[1]["receipt"].endswith("(resend)")
+105
View File
@@ -0,0 +1,105 @@
"""Summarise glue: gap merging, per-paper batching, verification consistency, revise-once policy."""
from __future__ import annotations
from _fakes import FakeProvider, make_digest, make_paper, make_payload, make_summary
from sebrief.llm.base import LLMError
from sebrief.models import PaperSummaryItem, Verification, VerificationIssue
from sebrief.summarize import summarize_papers, summarize_payload, verify_and_revise
def test_summarize_merges_payload_gaps_into_digest():
payload = make_payload(gaps=["openalex: down"])
provider = FakeProvider(digest=make_digest(data_gaps=["LLM이 적은 누락"]))
digest, model = summarize_payload(payload, provider)
assert model == "fake-model"
assert digest.data_gaps == ["openalex: down", "LLM이 적은 누락"]
def test_summarize_dedups_gaps():
payload = make_payload(gaps=["g1"])
provider = FakeProvider(digest=make_digest(data_gaps=["g1", "g2"]))
digest, _ = summarize_payload(payload, provider)
assert digest.data_gaps == ["g1", "g2"]
# -------------------------------------------------------------------- per-paper summaries
def test_summarize_papers_batches_and_maps_by_doi():
papers = [make_paper(f"10.1/p{i}") for i in range(5)]
provider = FakeProvider()
summaries, notes = summarize_papers(papers, provider, batch_size=2)
assert notes == []
assert set(summaries) == {p.doi for p in papers}
assert provider.calls == ["summarize_papers:2", "summarize_papers:2", "summarize_papers:1"]
assert summaries["10.1/p0"].tldr == "10.1/p0 한 줄 요약입니다."
def test_summarize_papers_skips_already_summarized_and_abstractless():
done = make_paper("10.1/done", summary=make_summary())
no_abstract = make_paper("10.1/empty", abstract=" ")
todo = make_paper("10.1/todo")
provider = FakeProvider()
summaries, _ = summarize_papers([done, no_abstract, todo], provider, batch_size=8)
assert set(summaries) == {"10.1/todo"}
assert provider.calls == ["summarize_papers:1"]
def test_summarize_papers_failed_batch_is_noted_and_others_survive():
class FlakyProvider(FakeProvider):
def summarize_papers(self, papers):
if any(p.doi == "10.1/p0" for p in papers):
raise LLMError("boom")
return super().summarize_papers(papers)
papers = [make_paper(f"10.1/p{i}") for i in range(4)]
summaries, notes = summarize_papers(papers, FlakyProvider(), batch_size=2)
assert set(summaries) == {"10.1/p2", "10.1/p3"}
assert len(notes) == 1 and "batch 1/2" in notes[0] and "재시도" in notes[0]
def test_summarize_papers_discards_unknown_dois():
class InventingProvider(FakeProvider):
def summarize_papers(self, papers):
items, model = super().summarize_papers(papers)
items.append(PaperSummaryItem(doi="10.9/invented", **make_summary().model_dump()))
return items, model
summaries, notes = summarize_papers([make_paper("10.1/a")], InventingProvider(), batch_size=8)
assert set(summaries) == {"10.1/a"} and notes == []
def test_verify_ok_returns_original():
provider = FakeProvider(verifications=[Verification(ok=True, issues=[])])
digest = make_digest()
out, verification = verify_and_revise(make_payload(), digest, provider)
assert out is digest and verification.ok
assert provider.calls == ["verify"]
def test_minor_only_issues_force_ok_true_and_skip_revision():
minor = Verification(ok=False, issues=[VerificationIssue(claim="c", problem="p", severity="minor")])
provider = FakeProvider(verifications=[minor])
_out, verification = verify_and_revise(make_payload(), make_digest(), provider)
assert verification.ok is True and len(verification.issues) == 1
assert provider.calls == ["verify"]
def test_major_issue_revises_once_and_reverifies():
major = Verification(ok=False, issues=[VerificationIssue(claim="c", problem="p", severity="major")])
clean = Verification(ok=True, issues=[])
revised = make_digest(overview="수정된 총평입니다.")
provider = FakeProvider(verifications=[major, clean], revised=revised)
out, verification = verify_and_revise(make_payload(), make_digest(), provider)
assert out.overview == "수정된 총평입니다."
assert verification.ok is True
assert provider.calls == ["verify", "revise", "verify"]
def test_failed_revision_keeps_original_and_verdict():
major = Verification(ok=False, issues=[VerificationIssue(claim="c", problem="p", severity="major")])
provider = FakeProvider(verifications=[major], fail_revise=True)
digest = make_digest()
out, verification = verify_and_revise(make_payload(), digest, provider)
assert out is digest
assert verification.has_major and verification.ok is False
+116
View File
@@ -0,0 +1,116 @@
"""Week helpers and WeeklyClock schedule arithmetic."""
from __future__ import annotations
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from sebrief.week import WeeklyClock, parse_hhmm, week_id, week_range
KST = ZoneInfo("Asia/Seoul")
# ------------------------------------------------------------------------- week id / range
def test_week_id_and_range_roundtrip():
assert week_id(date(2026, 8, 31)) == "2026-W36"
assert week_id(date(2026, 9, 6)) == "2026-W36"
assert week_range("2026-W36") == (date(2026, 8, 31), date(2026, 9, 6))
def test_week_id_uses_iso_year_at_boundaries():
# 2026-01-01 is a Thursday -> belongs to ISO 2026-W01 starting Mon 2025-12-29
assert week_id(date(2025, 12, 29)) == "2026-W01"
assert week_range("2026-W01")[0] == date(2025, 12, 29)
@pytest.mark.parametrize("bad", ["2026-36", "2026-W99", "2026W36", "abcd-W01", "2026-W1"])
def test_week_range_rejects_invalid(bad):
with pytest.raises(ValueError):
week_range(bad)
@pytest.mark.parametrize("bad", ["8:", "25:00", "08:60", "abc", "0820"])
def test_parse_hhmm_rejects_invalid(bad):
with pytest.raises(ValueError):
parse_hhmm(bad)
def test_parse_hhmm_ok():
assert parse_hhmm("07:00") == (7, 0)
assert parse_hhmm(" 7:05 ") == (7, 5)
# ------------------------------------------------------------------------------ WeeklyClock
def test_clock_rejects_bad_config():
with pytest.raises(ValueError):
WeeklyClock(run_weekday=7)
with pytest.raises(ValueError):
WeeklyClock(run_time="25:00")
def test_next_run_before_and_after_the_weekly_slot():
clock = WeeklyClock(0, "07:00")
before = datetime(2026, 9, 7, 6, 59, tzinfo=KST) # Monday, one minute before the run
week, run_at = clock.next_run(before)
assert (week, run_at) == ("2026-W36", datetime(2026, 9, 7, 7, 0, tzinfo=KST))
after = datetime(2026, 9, 7, 7, 0, tzinfo=KST) # exactly at the slot -> next week's run
week, run_at = clock.next_run(after)
assert (week, run_at) == ("2026-W37", datetime(2026, 9, 14, 7, 0, tzinfo=KST))
def test_next_run_midweek_and_custom_weekday():
wed = datetime(2026, 9, 9, 12, 0, tzinfo=KST)
assert WeeklyClock(0, "07:00").next_run(wed) == (
"2026-W37",
datetime(2026, 9, 14, 7, 0, tzinfo=KST),
)
# Thursday 08:30 runs cover the week that ended the previous Sunday
assert WeeklyClock(3, "08:30").next_run(wed) == (
"2026-W36",
datetime(2026, 9, 10, 8, 30, tzinfo=KST),
)
def test_next_run_requires_tz_aware():
with pytest.raises(ValueError):
WeeklyClock().next_run(datetime(2026, 9, 7, 12, 0)) # noqa: DTZ001 - naive on purpose
def test_run_at_for_is_consistent_with_next_run():
clock = WeeklyClock(0, "07:00")
assert clock.run_at_for("2026-W36") == datetime(2026, 9, 7, 7, 0, tzinfo=KST)
clock2 = WeeklyClock(3, "08:30")
assert clock2.run_at_for("2026-W36") == datetime(2026, 9, 10, 8, 30, tzinfo=KST)
def test_last_run_and_latest_completed_week():
clock = WeeklyClock(0, "07:00")
monday_noon = datetime(2026, 9, 7, 12, 0, tzinfo=KST)
assert clock.last_run(monday_noon) == ("2026-W36", datetime(2026, 9, 7, 7, 0, tzinfo=KST))
assert clock.latest_completed_week(monday_noon) == "2026-W36"
# mid-week: the last run is still the past Monday
wed = datetime(2026, 9, 9, 9, 0, tzinfo=KST)
assert clock.last_run(wed) == ("2026-W36", datetime(2026, 9, 7, 7, 0, tzinfo=KST))
@pytest.mark.parametrize(
("now_delta", "expected"),
[
(timedelta(hours=1), "2026-W36"), # 1h after the slot: inside the window
(timedelta(hours=71), "2026-W36"), # still inside the 72h window
(timedelta(hours=73), None), # outside
],
)
def test_catchup_candidate_window(now_delta, expected):
clock = WeeklyClock(0, "07:00")
run_at = datetime(2026, 9, 7, 7, 0, tzinfo=KST)
assert clock.catchup_candidate(run_at + now_delta, 72) == expected
def test_covered_week():
clock = WeeklyClock(0, "07:00")
assert clock.covered_week(date(2026, 9, 7)) == "2026-W36"
assert clock.covered_week(date(2026, 9, 14)) == "2026-W37"