"""AI-assisted holiday entry: what the model returns is a claim, not data. The tests that matter here are the ones that keep a model mistake from becoming a wrong holiday in the calendar: an unreadable date, a country that does not resolve, a makeup workday misfiled as a day off, a row for the wrong year. Every one of those must drop with a reason HR can read, and the model must never write to the database — HR confirms candidates through the ordinary `/bulk` path. """ from __future__ import annotations from datetime import date import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.domains.hr import holiday_ai_service as ai from app.domains.hr import holiday_service as svc from app.domains.hr.holiday_models import ( MAKEUP_WORKDAY, PUBLIC_HOLIDAY, HrPublicHolidayCalendar, name_key_for, ) from app.integrations.models import PlatformSetting pytestmark = pytest.mark.asyncio BASE = "/api/v1/hr/holidays" class _FakeLLM: """Stands in for BailianLLMClient. Records the prompt it was handed.""" def __init__(self, *, json_reply=None, text_reply=None, raises=False): self.json_reply = json_reply self.text_reply = text_reply self.raises = raises self.last_user: str | None = None async def chat_json(self, *, system, user, max_tokens=0, temperature=0.0): self.last_user = user if self.raises: raise RuntimeError("model unavailable") return self.json_reply async def chat_text(self, *, system, user, max_tokens=0, temperature=0.0): self.last_user = user if self.raises: raise RuntimeError("model unavailable") return self.text_reply def _install(monkeypatch, fake) -> None: async def _factory(_db): return fake monkeypatch.setattr(ai, "llm_client", _factory) async def _enable_key(db: AsyncSession) -> None: db.add(PlatformSetting(key="ai_spending.llm.api_key", value="sk-test")) await db.flush() await db.commit() # ──────────────────────── row validation ──────────────────────── async def test_parse_maps_a_clean_model_reply( db_session: AsyncSession, monkeypatch, ): _install(monkeypatch, _FakeLLM(json_reply={ "rows": [ {"country": "CN", "date": "2026-05-01", "name_en": "Labor Day Holiday", "name_local": "劳动节", "day_type": "public_holiday"}, {"country": "CN", "date": "2026-05-09", "name_en": "Makeup Workday (Labor Day Holiday)", "name_local": "劳动节调休上班", "day_type": "makeup_workday"}, ], "notes": "", })) outcome = await ai.parse_text(db_session, text="劳动节 5/1-5/5, 5/9 补班") assert outcome.engine == "llm" assert [r.day for r in outcome.rows] == [date(2026, 5, 1), date(2026, 5, 9)] assert [r.day_type for r in outcome.rows] == [PUBLIC_HOLIDAY, MAKEUP_WORKDAY] assert outcome.rows[0].name_local == "劳动节" assert outcome.dropped == [] async def test_parse_drops_rows_it_cannot_trust_with_a_reason( db_session: AsyncSession, monkeypatch, ): _install(monkeypatch, _FakeLLM(json_reply={ "rows": [ {"country": "CN", "date": "2026-05-01", "name_en": "Good Row"}, {"country": "Freedonia", "date": "2026-05-02", "name_en": "Bad Country"}, {"country": "CN", "date": "not-a-date", "name_en": "Bad Date"}, {"country": "CN", "date": "2026-05-03", "name_en": ""}, {"country": "CN", "date": "2026-05-04", "name_en": "Bad Type", "day_type": "vacation"}, {"country": "CN", "date": "1899-05-05", "name_en": "Ancient"}, "not even an object", ], "notes": "", })) outcome = await ai.parse_text(db_session, text="whatever") assert [r.name_en for r in outcome.rows] == ["Good Row"] assert len(outcome.dropped) == 6 joined = " | ".join(outcome.dropped) assert "no resolvable country" in joined assert "unreadable date" in joined assert "no holiday name" in joined assert "unknown day_type" in joined assert "out of range" in joined async def test_parse_uses_the_country_hint_when_the_model_omits_one( db_session: AsyncSession, monkeypatch, ): fake = _FakeLLM(json_reply={ "rows": [{"date": "2026-07-20", "name_en": "Marine Day"}], "notes": "", }) _install(monkeypatch, fake) outcome = await ai.parse_text(db_session, text="Marine Day", country_hint="jp") assert [r.country for r in outcome.rows] == ["JP"] # The hint has to reach the prompt, or the model cannot honour it either. assert "JP" in (fake.last_user or "") async def test_parse_rejects_rows_outside_the_year_hint( db_session: AsyncSession, monkeypatch, ): """A notice mentioning last year's dates in passing must not add them.""" _install(monkeypatch, _FakeLLM(json_reply={ "rows": [ {"country": "CN", "date": "2026-05-01", "name_en": "This Year"}, {"country": "CN", "date": "2025-05-01", "name_en": "Last Year"}, ], "notes": "", })) outcome = await ai.parse_text(db_session, text="x", year_hint=2026) assert [r.name_en for r in outcome.rows] == ["This Year"] assert any("not in 2026" in d for d in outcome.dropped) async def test_parse_flags_duplicates_against_the_calendar( db_session: AsyncSession, monkeypatch, ): _install(monkeypatch, _FakeLLM(json_reply={ "rows": [ {"country": "CN", "date": "2026-05-01", "name_en": "Labor Day"}, {"country": "CN", "date": "2026-05-02", "name_en": "Labor Day"}, ], "notes": "", })) existing = {("CN", "2026-05-01", name_key_for("Labor Day"))} outcome = await ai.parse_text(db_session, text="x", existing_keys=existing) assert [r.duplicate for r in outcome.rows] == [True, False] assert outcome.payload()["counts"] == {"new": 1, "duplicate": 1, "dropped": 0} async def test_parse_collapses_a_repeated_row_from_one_reply( db_session: AsyncSession, monkeypatch, ): _install(monkeypatch, _FakeLLM(json_reply={ "rows": [ {"country": "CN", "date": "2026-05-01", "name_en": "Labor Day"}, {"country": "CN", "date": "2026-05-01", "name_en": "labor day"}, ], "notes": "", })) outcome = await ai.parse_text(db_session, text="x") assert len(outcome.rows) == 1 async def test_parse_caps_a_runaway_reply(db_session: AsyncSession, monkeypatch): rows = [ {"country": "CN", "date": f"2026-01-{(i % 28) + 1:02d}", "name_en": f"Holiday {i}"} for i in range(ai.MAX_PARSED_ROWS + 50) ] _install(monkeypatch, _FakeLLM(json_reply={"rows": rows, "notes": ""})) outcome = await ai.parse_text(db_session, text="x") assert len(outcome.rows) <= ai.MAX_PARSED_ROWS assert any("only the first" in d for d in outcome.dropped) # ──────────────────────── degradation ──────────────────────── async def test_parse_falls_back_to_a_text_scan_with_no_key( db_session: AsyncSession, ): """No LLM key configured must still give HR working rows, not a dead end.""" text = "\n".join([ "2026-05-01 Labor Day", "2026-05-02 Labor Day", "2026-05-09 劳动节补班", "this line has no date", ]) outcome = await ai.parse_text(db_session, text=text, country_hint="CN") assert outcome.engine == "deterministic" assert [r.day for r in outcome.rows] == [ date(2026, 5, 1), date(2026, 5, 2), date(2026, 5, 9), ] assert outcome.rows[2].day_type == MAKEUP_WORKDAY assert "no llm key" in outcome.notes.lower() async def test_text_scan_reads_the_source_documents_date_format( db_session: AsyncSession, ): text = "\n".join([ "Thursday, January 1, 2026 | New Year's Day", "2026年2月17日 春节", "2026/04/06 Easter Monday", ]) outcome = await ai.parse_text(db_session, text=text, country_hint="CN") assert [r.day for r in outcome.rows] == [ date(2026, 1, 1), date(2026, 2, 17), date(2026, 4, 6), ] # The weekday word must not survive into the holiday name. assert outcome.rows[0].name_en == "New Year's Day" assert outcome.rows[1].name_local == "春节" async def test_text_scan_without_a_country_says_so_instead_of_guessing( db_session: AsyncSession, ): outcome = await ai.parse_text(db_session, text="2026-05-01 Labor Day") assert outcome.rows == [] assert "needs a country" in outcome.notes async def test_parse_falls_back_when_the_model_raises( db_session: AsyncSession, monkeypatch, ): _install(monkeypatch, _FakeLLM(raises=True)) outcome = await ai.parse_text( db_session, text="2026-05-01 Labor Day", country_hint="CN", ) assert outcome.engine == "deterministic" assert len(outcome.rows) == 1 assert "failed" in outcome.notes.lower() async def test_parse_falls_back_when_the_model_returns_nothing_usable( db_session: AsyncSession, monkeypatch, ): """An empty model reply is not proof the text has no holidays.""" _install(monkeypatch, _FakeLLM(json_reply={"rows": [], "notes": "unsure"})) outcome = await ai.parse_text( db_session, text="2026-05-01 Labor Day", country_hint="CN", ) assert outcome.engine == "deterministic" assert len(outcome.rows) == 1 async def test_parse_handles_a_json_string_reply( db_session: AsyncSession, monkeypatch, ): _install(monkeypatch, _FakeLLM( json_reply='{"rows": [{"country": "CN", "date": "2026-05-01", ' '"name_en": "Labor Day"}], "notes": "ok"}', )) outcome = await ai.parse_text(db_session, text="x") assert len(outcome.rows) == 1 assert outcome.notes == "ok" async def test_parse_returns_empty_for_empty_input(db_session: AsyncSession): outcome = await ai.parse_text(db_session, text=" ") assert outcome.rows == [] assert outcome.engine == "none" # ──────────────────────── closing line ──────────────────────── def _group() -> svc.HolidayGroup: return svc.HolidayGroup( country="CN", group_key="cn-2026-mid-autumn", name_en="Mid-Autumn Festival", name_local="中秋节", note=None, days=[date(2026, 9, d) for d in (25, 26, 27)], makeup_days=[], ) async def test_closing_uses_the_model_reply(db_session: AsyncSession, monkeypatch): _install(monkeypatch, _FakeLLM(text_reply="月色正好,愿你与家人共度良夜,节后见。")) result = await ai.suggest_closing(db_session, group=_group()) assert result["engine"] == "llm" assert result["closing"] == "月色正好,愿你与家人共度良夜,节后见。" async def test_closing_strips_fences_and_quotes( db_session: AsyncSession, monkeypatch, ): _install(monkeypatch, _FakeLLM(text_reply='```\n"月色正好,节后见。"\n```')) result = await ai.suggest_closing(db_session, group=_group()) assert result["closing"] == "月色正好,节后见。" async def test_closing_falls_back_when_the_model_ignores_the_brief( db_session: AsyncSession, monkeypatch, ): """A three-paragraph answer is not a closing line; truncating it looks worse.""" _install(monkeypatch, _FakeLLM(text_reply="很长的回答。" * 80)) result = await ai.suggest_closing(db_session, group=_group()) assert result["engine"] == "deterministic" # The seasonal September default. assert "秋" in str(result["closing"]) async def test_closing_falls_back_with_no_key(db_session: AsyncSession): result = await ai.suggest_closing(db_session, group=_group()) assert result["engine"] == "deterministic" assert str(result["note"]).startswith("No LLM key") async def test_closing_falls_back_when_the_model_raises( db_session: AsyncSession, monkeypatch, ): _install(monkeypatch, _FakeLLM(raises=True)) result = await ai.suggest_closing(db_session, group=_group()) assert result["engine"] == "deterministic" # ──────────────────────── endpoints ──────────────────────── async def test_ai_parse_endpoint_requires_hr( client: AsyncClient, employee_headers, ): resp = await client.post( f"{BASE}/ai/parse", headers=employee_headers, json={"text": "2026-05-01 X"}, ) assert resp.status_code == 403 async def test_ai_parse_endpoint_writes_nothing( client: AsyncClient, hr_headers, db_session: AsyncSession, monkeypatch, ): """The model's output is a suggestion; only /bulk writes.""" _install(monkeypatch, _FakeLLM(json_reply={ "rows": [{"country": "CN", "date": "2026-05-01", "name_en": "Labor Day"}], "notes": "", })) await _enable_key(db_session) resp = await client.post( f"{BASE}/ai/parse", headers=hr_headers, json={"text": "劳动节 5 月 1 日", "country": "CN", "year": 2026}, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["counts"]["new"] == 1 assert data["rows"][0]["day"] == "2026-05-01" # Nothing landed in the calendar. assert await svc.list_days(db_session, countries=["CN"]) == [] async def test_ai_parse_then_bulk_commit_lands_the_rows( client: AsyncClient, hr_headers, db_session: AsyncSession, monkeypatch, ): _install(monkeypatch, _FakeLLM(json_reply={ "rows": [ {"country": "CN", "date": "2026-05-01", "name_en": "Labor Day Holiday", "name_local": "劳动节"}, {"country": "CN", "date": "2026-05-09", "name_en": "Makeup Workday (Labor Day Holiday)", "day_type": "makeup_workday"}, ], "notes": "", })) await _enable_key(db_session) parsed = await client.post( f"{BASE}/ai/parse", headers=hr_headers, json={"text": "x", "country": "CN", "year": 2026}, ) rows = [ { "country": r["country"], "day": r["day"], "name_en": r["name_en"], "name_local": r["name_local"], "day_type": r["day_type"], } for r in parsed.json()["data"]["rows"] ] committed = await client.post( f"{BASE}/bulk", headers=hr_headers, json={"rows": rows, "source": "ai_import"}, ) assert committed.json()["data"]["created"] == 2 stored = await svc.list_days(db_session, countries=["CN"]) assert {r.source for r in stored} == {"ai_import"} # The makeup row joined the holiday's group rather than forming its own. assert len(svc.build_groups(stored)) == 1 async def test_ai_parse_endpoint_rejects_oversized_text( client: AsyncClient, hr_headers, ): resp = await client.post( f"{BASE}/ai/parse", headers=hr_headers, json={"text": "x" * 20_001}, ) assert resp.status_code == 422 async def test_closing_endpoint_returns_a_line( client: AsyncClient, hr_headers, db_session: AsyncSession, monkeypatch, ): db_session.add(HrPublicHolidayCalendar( country="CN", year=2026, label="China", source="seed", )) await db_session.flush() group_key, _rows = await svc.create_group( db_session, country="CN", name_en="Mid-Autumn Festival", name_local="中秋节", days=[date(2026, 9, 25)], makeup_days=[], source="seed", ) await db_session.commit() _install(monkeypatch, _FakeLLM(text_reply="月色正好,节后见。")) await _enable_key(db_session) resp = await client.post( f"{BASE}/notice/closing", headers=hr_headers, json={"country": "CN", "group_key": group_key, "language": "zh"}, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["closing"] == "月色正好,节后见。" async def test_closing_endpoint_404s_for_an_unknown_group( client: AsyncClient, hr_headers, ): resp = await client.post( f"{BASE}/notice/closing", headers=hr_headers, json={"country": "CN", "group_key": "nope"}, ) assert resp.status_code == 404 async def test_closing_endpoint_requires_hr(client: AsyncClient, employee_headers): resp = await client.post( f"{BASE}/notice/closing", headers=employee_headers, json={"country": "CN", "group_key": "x"}, ) assert resp.status_code == 403