first commit
This commit is contained in:
+244
@@ -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"
|
||||
@@ -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***"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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 & Coverage</title>
|
||||
<link>https://doi.org/10.37190/e-inf/2026.1</link>
|
||||
<description><p>We study <b>mutation</b> testing.</p></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> 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) == []
|
||||
@@ -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
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user