Skip to content

Commit 5a3c24f

Browse files
FlyM1ssclaude
andauthored
feat(api): add /api/news/items/ raw news-items endpoint (#359)
* test(api): add TDD suite for /api/news/items/ (Phase B, ATL integration) 17 cases pinning the raw news-items response contract: newest-by-mtime selection, limit clamping [1,200], conditional GET/ETag variance, poison-pill vs drop-story validation semantics, and security parity (control/bidi strip, NEWS_DATA marker, field caps). Written red against the not-yet-existing news_items view/RAW_ITEMS_DIR setting. * feat(api): add /api/news/items/ raw news-items endpoint Phase B of the ATL news-signals integration plan: serves the newest items-*.jsonl batch, ungated (no subject/roundup/LLM gating — that stays news_signals-only). Validation/sanitization (_validate_items/_clean_text) is a byte-faithful port of Heartbeat/news_signals.py's validation_gate/ clean_text, since that script is deliberately isolated and not importable from Django. Same decorator stack, fail-closed 404 contract, and per-request memoization pattern as news_signals. Also extends test_api_auth.py with dedicated 401/dev-mode/valid-bearer coverage for the new route. Suite: 738 passed, 1 skipped (pre-existing). * chore(deploy): mount heartbeat digests dir for /api/news/items/ Sibling of the existing signals :ro mount + env: without this the endpoint 404s in prod (RAW_ITEMS_DIR unset -> unconfigured -> fail-closed). * fix(api): fail-closed on corrupt tickers + no re-stat in conditional-GET + regex port fidelity - Guard the tickers comprehension in _validate_items against non-string elements (AttributeError -> 500) and non-list values (char-iteration -> silently wrong tickers), dropping only the bad entries per the module's never-500 contract. - Thread the newest batch's mtime through the _load_items memo instead of re-stat()ing in _items_etag/_items_last_modified, closing a TOCTOU window where a pruned batch could 500 instead of 404. - Restore double-backslash regex escapes in _CONTROL_RE/_LINEBREAK_RE to match Heartbeat/news_signals.py verbatim (ASCII-only .pattern). * feat(api): items endpoint speaks news-story v1 (headline/url + schema_version) * fix(news): harden the items gate and pin the port to its source Review findings on #359: - clean_text/validation_gate: non-str required fields drop the story (same stance as the numeric parse) instead of raising; clean_text is now total. Fixes a live crash in news_signals.py, where a corrupt "tickers":[123] raised AttributeError past process_batch's ValueError-only except and aborted the whole sweep. - Both copies (Heartbeat + the Django port) fixed together and pinned by Heartbeat/tests/test_port_parity.py, which AST-extracts the ported region and compares behaviour without importing Django. Corpus is mutation-proven: it goes red on the drift it claims to catch. - _MAX_ITEMS_FILE_MB no longer hardcoded — settings.RAW_ITEMS_MAX_FILE_MB reads SIGNALS_MAX_FILE_MB, so one operator knob moves both readers. - signals_views.py added to heartbeat-tests.yml pull_request paths only: the push trigger also gates the deploy job (paths scope the run, not the job), so listing it there would redeploy the droplet on a Django-only merge. - Coverage for the ?limit Last-Modified suppression, the recency upper bound, and the blank-line skip — each mutation-verified. VERSION -> 2026-07-14.1 (deployed behaviour changed); fixture regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(api): document GET /api/news/items/ - api_reference: endpoints-table row + a News Items section (limit clamp, no as_of, fail-closed 404, ETag/Last-Modified variance). Notes that its `score` is the editorial score, not the [-1,1] sentiment score of the same name under /api/signals/news/. - project_structure: signals_views.py hosts both news endpoints now. - pipeline design spec: SIGNALS_MAX_FILE_MB has a second reader. Sphinx build clean (0 warnings). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f52054b commit 5a3c24f

16 files changed

Lines changed: 1275 additions & 15 deletions

.github/workflows/backend-deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ jobs:
313313
cat > "$OVERRIDE_DIR/override.conf" <<EOF
314314
[Service]
315315
ExecStart=
316-
ExecStart=/usr/bin/podman run --name ${SYSTEMD_UNIT} --replace --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,mode=0755 --tmpfs=/home/fingpt:rw,mode=0755 --cgroups=split --sdnotify=conmon -d --memory=1.7g --memory-swap=2g --network fingpt-net -v /home/deploy/fingpt/runtime:/app/runtime:U,Z -v /home/deploy/fingpt/heartbeat/signals:/app/signals:ro,Z --env SIGNALS_DIR=/app/signals --publish 127.0.0.1:8000:8000 --env-file /home/deploy/fingpt/envs/.env.production --env REDIS_URL=redis://fingpt-redis:6379/0 ${REMOTE_IMAGE}
316+
ExecStart=/usr/bin/podman run --name ${SYSTEMD_UNIT} --replace --rm --cap-drop=ALL --cap-add=NET_ADMIN --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID --cap-add=SETPCAP --pids-limit=1024 --read-only --tmpfs=/tmp:rw,size=512m,mode=1777 --tmpfs=/app/staticfiles:rw,mode=0755 --tmpfs=/home/fingpt:rw,mode=0755 --cgroups=split --sdnotify=conmon -d --memory=1.7g --memory-swap=2g --network fingpt-net -v /home/deploy/fingpt/runtime:/app/runtime:U,Z -v /home/deploy/fingpt/heartbeat/signals:/app/signals:ro,Z --env SIGNALS_DIR=/app/signals -v /home/deploy/fingpt/heartbeat/digests:/app/digests:ro,Z --env RAW_ITEMS_DIR=/app/digests --publish 127.0.0.1:8000:8000 --env-file /home/deploy/fingpt/envs/.env.production --env REDIS_URL=redis://fingpt-redis:6379/0 ${REMOTE_IMAGE}
317317
EOF
318318
319319
systemctl --user daemon-reload

.github/workflows/heartbeat-tests.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,23 @@ on:
77
paths:
88
- "Heartbeat/**"
99
- ".github/workflows/heartbeat-tests.yml"
10+
# Main/backend/api/signals_views.py is deliberately NOT listed here,
11+
# only on pull_request below: this trigger also gates the deploy job,
12+
# whose sole condition is the main-ref check (paths scope the workflow
13+
# RUN, not a job), so a Django-only merge would redeploy the droplet's
14+
# heartbeat scripts — silently shipping any held-back rollout on a
15+
# commit that touched no Heartbeat file.
1016
pull_request:
1117
paths:
1218
- "Heartbeat/**"
1319
- ".github/workflows/heartbeat-tests.yml"
20+
# The backend vendors a port of news_signals.py's validation gate
21+
# (clean_text/validation_gate -> _clean_text/_validate_items) and
22+
# Heartbeat/tests/test_port_parity.py is what pins the two copies
23+
# together, so a port-side edit must run this suite. PR-time is the
24+
# right gate: the point is to make drift impossible to MERGE, and
25+
# deploy skips on PR refs anyway.
26+
- "Main/backend/api/signals_views.py"
1427
workflow_dispatch:
1528

1629
env:

Docs/source/api_reference.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,9 @@ the signed ``fingpt_sessionid`` cookie. Callers may pass an optional
571571
* - GET
572572
- ``/api/signals/news/``
573573
- Latest news→sentiment signals artifact
574+
* - GET
575+
- ``/api/news/items/``
576+
- Raw news stories from the newest Heartbeat batch
574577

575578
All share the ``API_RATE_LIMIT`` budget (``429 {"error": "rate_limited"}``
576579
when exceeded). The chat endpoints can also return ``503 {"error": "busy"}``
@@ -727,6 +730,44 @@ an ``ETag`` validator and ``Cache-Control: public, max-age=300``.
727730
variants are ETag-only, so conditional requests for them must use
728731
``If-None-Match``.
729732

733+
News Items
734+
~~~~~~~~~~
735+
736+
``GET /api/news/items/`` serves the **raw news stories** of the newest
737+
Heartbeat batch — the corpus the signals above are derived from, before any
738+
LLM scoring. Like ``/api/signals/news/`` it is an integration surface for
739+
external consumers; the browser extension does not call it.
740+
741+
**Query parameters:**
742+
743+
- ``limit=N`` — how many stories to return, newest first. Clamped to
744+
``[1, 200]``; defaults to ``50`` when absent. A non-integer value returns
745+
``400 {"error": "bad_limit"}``.
746+
747+
There is no ``as_of`` here: this endpoint always reads the single newest
748+
batch.
749+
750+
**Response (200):** ``{schema_version, items, count, batch}``, where ``batch``
751+
names the source file and each entry of ``items`` is
752+
``{guid, headline, url, source, published, description, tickers, score}``.
753+
``published`` is epoch seconds.
754+
755+
.. note::
756+
757+
``score`` on this endpoint is the pipeline's **editorial** score — how
758+
newsworthy the story is, the gate that decides which stories become
759+
sentiment candidates (``SIGNALS_MIN_EDITORIAL_SCORE``). It is *not* the
760+
``[-1, 1]`` sentiment score of the same name under ``/api/signals/news/``.
761+
The two fields share a name and nothing else.
762+
763+
``404 {"error": "no_items"}`` when no batch exists, or when the newest one is
764+
unreadable or validates to zero stories — the endpoint never falls back to an
765+
older batch, and never returns a ``500``. Responses carry an ``ETag`` and
766+
``Cache-Control: public, max-age=300``. ``Last-Modified`` is sent only on the
767+
default-limit variant — an explicit ``?limit`` slices the batch differently,
768+
so those variants are ETag-only and conditional requests for them must use
769+
``If-None-Match``.
770+
730771
---
731772

732773
Usage Examples

Docs/source/project_structure.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Backend Structure
3838
├── api/ # REST API layer
3939
│ ├── views.py # Main API endpoints
4040
│ ├── openai_views.py # OpenAI-compatible API endpoints
41-
│ ├── signals_views.py # News-signals endpoint (GET /api/signals/news/)
41+
│ ├── signals_views.py # News endpoints (GET /api/signals/news/, GET /api/news/items/)
4242
│ ├── middleware/ # CORS and custom middleware
4343
│ ├── utils/ # API utility functions
4444
│ ├── apps.py # Django app configuration

Docs/superpowers/specs/2026-07-06-news-to-signals-pipeline-design.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ A machine-readable JSON Schema ships at `Heartbeat/schemas/signals-v1.schema.jso
197197

198198
## 5. Configuration (the v1 tuning surface)
199199

200-
Env vars read by `news_signals.py` (module constants as defaults). This is the surface future user-facing tuning builds on:
200+
Env vars read by `news_signals.py` (module constants as defaults). This is the surface future user-facing tuning builds on. All are pipeline-only except `SIGNALS_MAX_FILE_MB`, which the Django API reads too (see its row):
201201

202202
| Var | Default | Meaning |
203203
|-----|---------|---------|
@@ -209,7 +209,7 @@ Env vars read by `news_signals.py` (module constants as defaults). This is the s
209209
| `SIGNALS_THRESHOLD` | `0.20` | ± threshold for bullish/bearish label (40/60 band, empirically backed; was 0.15) |
210210
| `SIGNALS_DAMP_CAP` | `0.7` | Max \|score\| when under-corroborated |
211211
| `SIGNALS_DAMP_MIN_ARTICLES` | `2` | Corroboration needed for \|score\| > damp cap |
212-
| `SIGNALS_MAX_FILE_MB` | `10` | Reject oversized items files |
212+
| `SIGNALS_MAX_FILE_MB` | `10` | Reject oversized items files. **Two readers as of 2026-07-14:** `news_signals.py` (via `load_config`) and the Django API (via `settings.RAW_ITEMS_MAX_FILE_MB`, which `GET /api/news/items/` enforces when validating a batch). Deliberately one operator knob — raising it for the pipeline without raising it for the API would 404 a batch the pipeline happily accepted. Set it in `.env.production` alongside the heartbeat's own env file; the two defaults are pinned together by `Heartbeat/tests/test_port_parity.py`. |
213213
| `SIGNALS_STALENESS_ALERT_H` | `20` | Canary threshold (§6-C). Tuned, not arbitrary: the daily canary check runs 2 h after the daily beat, so a single fully-missed day leaves the newest artifact ~25.5 h old at the *next* day's check — a 30 h threshold would not cross that (it silently absorbs one entire missed day, only firing after a second consecutive miss); 20 h does. |
214214

215215
## 6. Failure policy (every mode decided)

Heartbeat/.env.heartbeat.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ DISCORD_CHANNEL_ID=
3636
# SIGNALS_DAMP_CAP=0.7
3737
# SIGNALS_DAMP_MIN_ARTICLES=2
3838
# SIGNALS_MAX_FILE_MB=10
39+
# NOTE: also read by the Django API (settings.RAW_ITEMS_MAX_FILE_MB) for
40+
# GET /api/news/items/ — this knob now has two readers. Update both env
41+
# files together, or the API will 404 a batch the pipeline accepted.
3942
# Rolling retention cap: keep only the N most recent signals-*.json artifacts;
4043
# older ones are pruned after each successful sweep. Bounds signals/ growth.
4144
# SIGNALS_KEEP_N=14

Heartbeat/README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ fallback), writes a digest log, and posts it to a Discord channel.
1414

1515
The production droplet is 1 vCPU / 2 GB RAM with no pip on the host, so
1616
`news_heartbeat.py` is a **single stdlib-only Python file** — deploying is
17-
copying one file; running costs ~30 MB RSS. It is fully decoupled from the
18-
`fingpt-api` container. Summaries are short, attributed, and always link out to
19-
the source article (Yahoo ToS posture: orchestration, not raw-data
20-
redistribution).
17+
copying one file; running costs ~30 MB RSS. It still runs as an independent
18+
systemd service and never imports or depends on the Django app — the coupling
19+
is one-directional: the `fingpt-api` container mounts its `digests/` output
20+
read-only (as `RAW_ITEMS_DIR`) to serve `GET /api/news/items/`, and
21+
`Heartbeat/tests/test_port_parity.py` pins the API's vendored copy of the
22+
validation gate to this script. Summaries are short, attributed, and always
23+
link out to the source article (Yahoo ToS posture: orchestration, not
24+
raw-data redistribution).
2125

2226
## Run
2327

Heartbeat/news_signals.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from datetime import date, datetime, timezone
2525
from pathlib import Path
2626

27-
VERSION = "2026-07-11.2"
27+
VERSION = "2026-07-14.1"
2828
SCHEMA_VERSION = 1
2929
PROMPT_VERSION = 1
3030

@@ -45,6 +45,10 @@
4545
REQUIRED_FIELDS = ("guid", "title", "link", "source", "published", "score")
4646
FIELD_CAPS = {"title": 500, "description": 5000, "link": 2000, "source": 200,
4747
"guid": 200}
48+
# Required fields that must be strings. A malformed type drops the story — same
49+
# stance as the numeric parse in validation_gate — so a corrupt field can never
50+
# reach clean_text as a non-str and can never poison the whole batch.
51+
TEXT_REQUIRED_FIELDS = ("guid", "title", "link", "source")
4852
# Caps for the LLM/exception-derived output fields, pinned by the signals-v1
4953
# schema's maxLength values (headline/source/url are covered by FIELD_CAPS:
5054
# they pass through from the validated input). Same single-source-of-truth
@@ -109,9 +113,13 @@ def load_env_file(path):
109113

110114

111115
def clean_text(s, cap):
116+
# non-str (incl. None) collapses to "": the gate must never raise on a
117+
# malformed field type. Required-field types are checked in validation_gate;
118+
# this keeps optional and LLM-derived callers total on their own.
119+
s = s if isinstance(s, str) else ""
112120
# line boundaries first — CONTROL_RE would strip \v/\f/\x1c-\x1e to
113121
# nothing and fuse the words they separated
114-
s = _LINEBREAK_RE.sub(" ", unicodedata.normalize("NFC", s or ""))
122+
s = _LINEBREAK_RE.sub(" ", unicodedata.normalize("NFC", s))
115123
s = CONTROL_RE.sub("", s)
116124
s = s.replace("NEWS_DATA", "") # marker token can never come from the feed
117125
return s[:cap]
@@ -161,7 +169,8 @@ def load_config():
161169

162170
def validation_gate(path, max_file_mb):
163171
"""Input trust boundary (spec §7.1). Batch-level defects raise ValueError
164-
(poison pill, §6.1); a bad `published` drops only that story."""
172+
(poison pill, §6.1); a bad `published`, a malformed numeric, or a
173+
non-str TEXT_REQUIRED_FIELDS value drops only that story."""
165174
st = path.stat()
166175
if st.st_size > max_file_mb * 1024 * 1024:
167176
raise ValueError(f"file exceeds {max_file_mb}MB")
@@ -181,14 +190,23 @@ def validation_gate(path, max_file_mb):
181190
continue # malformed numeric types: drop the story, keep the batch
182191
if not (lo <= published <= hi):
183192
continue # forged/insane epoch: drop the story, keep the batch
193+
if not all(isinstance(story[f], str) for f in TEXT_REQUIRED_FIELDS):
194+
continue # malformed text types: drop the story, keep the batch
184195
story["published"] = published
185196
story["title"] = clean_text(story["title"], FIELD_CAPS["title"])
186197
story["description"] = clean_text(story.get("description", ""),
187198
FIELD_CAPS["description"])
188199
story["source"] = clean_text(story["source"], FIELD_CAPS["source"])
189-
story["guid"] = clean_text(str(story["guid"]), FIELD_CAPS["guid"])
190-
story["link"] = clean_text(str(story["link"]), FIELD_CAPS["link"])
191-
story["tickers"] = [t.upper() for t in story.get("tickers", [])]
200+
story["guid"] = clean_text(story["guid"], FIELD_CAPS["guid"])
201+
story["link"] = clean_text(story["link"], FIELD_CAPS["link"])
202+
# non-list/non-string tickers dropped, never crashed: a corrupt
203+
# "tickers":[123] must not .upper() -> AttributeError, which the only
204+
# caller (process_batch's ValueError-only except) would not catch and
205+
# which would abort the whole sweep; a bare "tickers":"AAPL" must not
206+
# char-iterate to ['A','A','P','L'].
207+
raw_tickers = story.get("tickers", [])
208+
story["tickers"] = ([t.upper() for t in raw_tickers if isinstance(t, str)]
209+
if isinstance(raw_tickers, list) else [])
192210
stories.append(story)
193211
return stories
194212

Heartbeat/tests/fixtures/signals-fixture.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"schema_version": 1,
33
"profile": "default",
44
"generated_at": "2026-07-06T15:00:00+00:00",
5-
"generator": "news_signals.py/2026-07-11.2",
5+
"generator": "news_signals.py/2026-07-14.1",
66
"model": "gpt-4o-mini",
77
"prompt_version": 1,
88
"source_items": "items-fixture.jsonl",

Heartbeat/tests/test_news_signals.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,27 @@ def test_clean_text_caps_and_handles_none(self):
8383
self.assertEqual(ns.clean_text(None, 5), "")
8484
self.assertEqual(ns.clean_text("x" * 10, 5), "xxxxx")
8585

86+
def test_clean_text_is_total_non_str_types_collapse_to_empty(self):
87+
# D1: clean_text's isinstance guard replaces the old "s or ''"
88+
# fallback and must never raise on a non-str input.
89+
import news_signals as ns
90+
for bad in (True, 123, [], {}):
91+
with self.subTest(bad=bad):
92+
self.assertEqual(ns.clean_text(bad, 100), "")
93+
94+
def test_clean_text_no_regression_for_every_input_the_old_fallback_handled(self):
95+
# D1 strict-superset proof: "s or ''" returned "" for every falsy
96+
# input and passed through every truthy str unchanged (modulo the
97+
# existing cleanup). Pin the exact old behavior for the falsy/edge
98+
# inputs that worked before this change, so a future edit can't
99+
# silently regress them.
100+
import news_signals as ns
101+
self.assertEqual(ns.clean_text("", 100), "")
102+
self.assertEqual(ns.clean_text(None, 100), "")
103+
self.assertEqual(ns.clean_text(0, 100), "")
104+
self.assertEqual(ns.clean_text(False, 100), "")
105+
self.assertEqual(ns.clean_text("0", 100), "0")
106+
86107
def test_clean_text_collapses_embedded_newlines_and_tabs(self):
87108
# A multi-line feed title must not let forged-looking lines reach
88109
# logs or artifact consumers: headline/rationale/source/guid are
@@ -257,6 +278,58 @@ def test_link_hygiene_strips_bidi(self):
257278
stories = ns.validation_gate(p, 10)
258279
self.assertNotIn("\u202e", stories[0]["link"])
259280

281+
def test_non_string_ticker_elements_are_dropped_not_crashed(self):
282+
# F2b regression: [t.upper() for t in tickers] would raise
283+
# AttributeError on a non-str element ('int' object has no attribute
284+
# 'upper'). run_sweep's ONLY except clause around process_batch is
285+
# `except ValueError as exc: # poison pill` (news_signals.py, the
286+
# run_sweep loop) \u2014 an AttributeError is NOT a ValueError, so it
287+
# would propagate uncaught and abort the whole sweep instead of just
288+
# dropping this one story. Must never raise.
289+
bad = make_story(guid="bad", tickers=[123, "msft", None, "nvda"])
290+
ok = make_story(guid="ok")
291+
p = write_items(self.td, [bad, ok])
292+
stories = ns.validation_gate(p, 10)
293+
self.assertEqual([s["guid"] for s in stories], ["bad", "ok"])
294+
self.assertEqual(stories[0]["tickers"], ["MSFT", "NVDA"])
295+
296+
def test_bare_string_and_non_list_tickers_become_empty_list(self):
297+
# A bare "tickers":"AAPL" must not char-iterate to ['A','A','P','L'].
298+
cases = [
299+
("str", "AAPL"),
300+
("none", None),
301+
("dict", {"a": 1}),
302+
]
303+
for name, val in cases:
304+
with self.subTest(tickers=name):
305+
p = write_items(self.td, [make_story(guid=name, tickers=val)],
306+
name=f"items-{name}.jsonl")
307+
stories = ns.validation_gate(p, 10)
308+
self.assertEqual(stories[0]["tickers"], [])
309+
310+
def test_non_string_required_text_field_drops_story_not_batch(self):
311+
# F2a's Heartbeat twin: title/source/guid/link must be str, else the
312+
# story is dropped (D2) rather than reaching clean_text with a type
313+
# that would raise inside unicodedata.normalize.
314+
bad_title = make_story(guid="bad-title", title=True) # JSON bool
315+
bad_source = make_story(guid="bad-source", source=123) # JSON number
316+
bad_guid = make_story(guid=456, title="fine")
317+
bad_link = make_story(guid="bad-link", link=789)
318+
ok = make_story(guid="ok")
319+
p = write_items(self.td, [bad_title, bad_source, bad_guid, bad_link, ok])
320+
stories = ns.validation_gate(p, 10)
321+
self.assertEqual([s["guid"] for s in stories], ["ok"])
322+
323+
def test_non_string_description_blanks_field_but_keeps_story(self):
324+
# description is optional and only reaches clean_text's own isinstance
325+
# guard (D1) \u2014 it must NOT be dropped like a TEXT_REQUIRED_FIELDS
326+
# violation, just blanked.
327+
s = make_story(guid="ok", description=True)
328+
p = write_items(self.td, [s])
329+
stories = ns.validation_gate(p, 10)
330+
self.assertEqual([s["guid"] for s in stories], ["ok"])
331+
self.assertEqual(stories[0]["description"], "")
332+
260333

261334
class TestSubjectGate(unittest.TestCase):
262335
def test_symbol_token_in_headline_is_subject(self):

0 commit comments

Comments
 (0)