131 lines
4.1 KiB
Python
131 lines
4.1 KiB
Python
|
|
"""Tests for the deterministic half of news_tagging: vocabulary filtering
|
||
|
|
and JSON-response parsing. The LLM call itself isn't exercised."""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.services.news_tagging import (
|
||
|
|
MAX_TAGS_PER_HEADLINE,
|
||
|
|
TAG_LABELS,
|
||
|
|
TAG_VOCABULARY,
|
||
|
|
_parse_batch_response,
|
||
|
|
_validate_tags,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Vocabulary integrity
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_every_vocab_tag_has_a_label():
|
||
|
|
"""Display labels must cover every tag — missing keys would render
|
||
|
|
the raw machine-name in the UI."""
|
||
|
|
for t in TAG_VOCABULARY:
|
||
|
|
assert t in TAG_LABELS, f"missing label for {t}"
|
||
|
|
|
||
|
|
|
||
|
|
def test_other_is_the_fallback_tag():
|
||
|
|
assert "other" in TAG_VOCABULARY
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _validate_tags
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_validate_drops_unknown_tags():
|
||
|
|
out = _validate_tags(["markets", "wibble", "tech"])
|
||
|
|
assert out == ["markets", "tech"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_validate_normalises_spaces_to_hyphens():
|
||
|
|
"""Common drift: model returns 'monetary policy' instead of
|
||
|
|
'monetary-policy'. We normalise."""
|
||
|
|
out = _validate_tags(["monetary policy"])
|
||
|
|
assert out == ["monetary-policy"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_validate_normalises_case():
|
||
|
|
out = _validate_tags(["MARKETS", "Geopolitics"])
|
||
|
|
assert out == ["markets", "geopolitics"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_validate_caps_at_max_tags():
|
||
|
|
out = _validate_tags(["markets", "tech", "china", "economy", "energy"])
|
||
|
|
assert len(out) == MAX_TAGS_PER_HEADLINE
|
||
|
|
|
||
|
|
|
||
|
|
def test_validate_dedupes():
|
||
|
|
out = _validate_tags(["markets", "markets", "tech"])
|
||
|
|
assert out == ["markets", "tech"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_validate_rejects_non_list():
|
||
|
|
assert _validate_tags(None) == []
|
||
|
|
assert _validate_tags("markets") == []
|
||
|
|
assert _validate_tags({"tag": "markets"}) == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_validate_skips_non_string_entries():
|
||
|
|
out = _validate_tags(["markets", 42, None, "tech"])
|
||
|
|
assert out == ["markets", "tech"]
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# _parse_batch_response
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_basic_json_array():
|
||
|
|
raw = '[{"id": 1, "tags": ["markets", "tech"]}, {"id": 2, "tags": ["china"]}]'
|
||
|
|
out = _parse_batch_response(raw, {1, 2})
|
||
|
|
assert out == {1: ["markets", "tech"], 2: ["china"]}
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_strips_leading_prose():
|
||
|
|
"""Models occasionally prepend 'Here is the output:' before the JSON."""
|
||
|
|
raw = 'Sure! Here are the tags:\n[{"id": 1, "tags": ["markets"]}]'
|
||
|
|
out = _parse_batch_response(raw, {1})
|
||
|
|
assert out == {1: ["markets"]}
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_strips_markdown_fences():
|
||
|
|
raw = "```json\n[{\"id\": 1, \"tags\": [\"tech\"]}]\n```"
|
||
|
|
out = _parse_batch_response(raw, {1})
|
||
|
|
assert out == {1: ["tech"]}
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_drops_unexpected_ids():
|
||
|
|
raw = '[{"id": 99, "tags": ["markets"]}, {"id": 1, "tags": ["tech"]}]'
|
||
|
|
out = _parse_batch_response(raw, {1, 2})
|
||
|
|
assert out == {1: ["tech"]}
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_empty_tags_falls_back_to_other():
|
||
|
|
"""An item whose tags list ends up empty after validation gets
|
||
|
|
tagged 'other' so the row is marked tagged, not left NULL."""
|
||
|
|
raw = '[{"id": 1, "tags": ["nonsense"]}]'
|
||
|
|
out = _parse_batch_response(raw, {1})
|
||
|
|
assert out == {1: ["other"]}
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_unparseable_returns_empty():
|
||
|
|
"""Garbage in → empty out. The caller leaves those rows untagged
|
||
|
|
so they get retried on the next run."""
|
||
|
|
assert _parse_batch_response("nope, no JSON here", {1}) == {}
|
||
|
|
assert _parse_batch_response("[invalid json", {1}) == {}
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_ignores_non_dict_items():
|
||
|
|
raw = '[{"id": 1, "tags": ["markets"]}, "lol", null, {"id": 2, "tags": ["tech"]}]'
|
||
|
|
out = _parse_batch_response(raw, {1, 2})
|
||
|
|
assert out == {1: ["markets"], 2: ["tech"]}
|
||
|
|
|
||
|
|
|
||
|
|
def test_parse_handles_string_id_coercion():
|
||
|
|
"""Some models render the id as a string. We coerce."""
|
||
|
|
raw = '[{"id": "1", "tags": ["markets"]}]'
|
||
|
|
out = _parse_batch_response(raw, {1})
|
||
|
|
assert out == {1: ["markets"]}
|