fix(analyze): never drop populated press context; harden gate + crawl (W30 incident) (#582)
* fix(analyze): never drop populated press context when synthesis narrative present Root cause of the 2026-W30 "No industry press data was available..." incident: _build_prompt blanked press_content whenever a Step-1 synthesis narrative was present (introduced in jmservera/SquadScope#515), so the Step-2 prompt had no Press Context block and the model emitted the no-press fallback despite a successful crawl (45 articles, 22 relevant, 32KB press-context.md). - analyze_fallback.py: when a synthesis narrative exists, keep the real press context (condensed to COMPACTED_PRESS_CONTEXT_CHARS) instead of blanking it, so "Where Industry Meets Code" / "Press & Industry" are written from real data. Sanitize press on this path (_strip_ai_instruction_blocks + _escape_untrusted_boundaries). press_decision now reflects reality ("included: condensed alongside synthesis narrative"). - prompts/analyze-weekly.md: emit the no-press line only when both press context and industry narrative are truly absent. - techcrunch_crawler.py: exponential backoff + jitter in fetch_feed (mirrors crawl.py), retry only transient failures (RETRYABLE_STATUSES) and fail fast on permanent HTTP errors; DEFAULT_FETCH_RETRIES 1->3. Partial results are preserved when a source fails, plus an explicit per-source success/failure stdout summary and ::warning:: on partial crawls (status also persisted in artifact metadata). - docs/operator-guide.md: document the analysis-only / press-only rerun path that reuses existing crawl artifacts without re-crawling GitHub or press. - tests: regression test that a synthesis narrative does not drop press context; crawler retry/backoff and fail-fast tests. Refs: jmservera/SquadScope#515, jmservera/SquadScope-Coordinator#33 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(gate): fail publish when body claims no press data but press context is populated Defense-in-depth for the 2026-W30 incident where a populated press-context.md was silently dropped and the analysis body shipped "No industry press data was available for this week's analysis." The existing contradiction rule only fires when the body ALSO describes press coverage; this adds a rule that fires even when the body does not self-contradict, as long as the week's press context is actually populated. - analysis_gate.py: add STALE_PRESS_CLAIM_PATTERNS (matches "no industry press data was available" and "no press data was provided this week"), stale_press_claim_errors(), and press_context_is_populated() which treats render_press_context.py's non-empty "No press data available for this week." sentinel as empty. New --press-context-path / --press-token-estimate args, wired through main() and validate_publish_quality(). Errors classify as editorial_quality (retryable). - crawl-and-publish.yml: pass --press-context-path to both gate invocations. - test: update mock signature to accept the new kwarg. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(press-context): lock 2026-W30 fix in analyze step and publish gate Strengthen and extend regression coverage for the 2026-W30 incident where a populated press-context.md was silently dropped and the analysis shipped "No industry press data was available for this week's analysis.". analyze_fallback: strengthen the synthesis-narrative test to assert the preflight press_correlations component stays included, add a case proving an oversized press context is condensed (not dropped) alongside a synthesis narrative, and add a case proving the no-press path is not over-corrected. analysis_gate: cover stale_press_claim_errors (fires when press context is available, silent otherwise, catches both phrasings), the validate_publish_quality wiring, and press_context_is_populated (missing/empty/sentinel vs real content and token_estimate>0). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(analyze): treat no-press sentinel as absent press context (PR #582 review) Resolve Copilot review: the non-empty "No press data available for this week..." sentinel from render_press_context was being misclassified as real press, which could suppress the required no-press statement and false-positive the stale-press gate on genuinely press-less weeks. - render_press_context: add single source of truth NO_PRESS_SENTINEL and NO_PRESS_SENTINEL_MARKER; render returns the constant. - analyze_fallback: blank press_content when it matches the sentinel at all press read sites, so no `## Press Context` block is emitted and the component is included=False. Real-press retention (synthesis narrative) is unchanged. - analysis_gate.press_context_is_populated: a provided path's sentinel content now wins over token_estimate>0 (returns False); imports the shared marker instead of a duplicated local regex. - tests: regression coverage for the press-less-week path in fallback suppression and the stale-press gate; real-press W30 behavior preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: apply ruff format to press-context fix files CI runs `ruff format --check`; formatting-only changes (blank lines, line wrapping). No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(press-context): wire --press-token-estimate fallback + correct operator doc Thread 1 (workflow): crawl-and-publish.yml now emits press_token_estimate as a step output and passes --press-token-estimate to analysis_gate.py in both the run-analysis and quality-check gate invocations. The gate already enforced precedence (readable press path authoritative; token estimate is fallback only when path is None/missing/empty/unreadable). This restores the fallback signal for the stale-press gate and matches the PR description. Thread 2 (doc): operator-guide.md now states the ## Press Context block appears only for real press; a press-less week renders the non-empty NO_PRESS_SENTINEL marker (single source of truth in render_press_context.py) which is suppressed (press_correlations.included=false). No hardcoded sentinel string. Test: add test_press_context_token_estimate_is_fallback_only proving the fallback branches and the sentinel-authoritative precedence. Refs jmservera/SquadScope#582 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(analyze): make synthesis-narrative label conditional on real press presence The Step-1 synthesis label hard-coded "press & historical context" even on press-less weeks (press_content empty / no-press sentinel suppressed), misleading the downstream model into thinking press exists. Make the label conditional on the already-normalized press_content emptiness (single source of truth); it now reads "historical context" only when press is absent. Adds regression coverage. Resolves the Copilot review thread on #582. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(press-context): emit genuine 0 token-estimate for empty press file estimate_tokens() clamps to min 1, so int(estimate_tokens(content) or 0) could never be 0 for an empty/whitespace press file, making the --press-token-estimate fallback misclassify an empty press file as populated in analysis_gate.press_context_is_populated(). Add press_token_estimate() helper (single source of truth, reuses estimate_tokens) that returns 0 for empty/whitespace content and a positive estimate otherwise, and wire the crawl-and-publish workflow to it. Readable press_context_path (real vs NO_PRESS_SENTINEL) remains authoritative; token estimate stays fallback-only. Add tests proving empty -> 0 -> not populated and real -> positive -> populated. Refs jmservera/SquadScope#582 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(crawler): honor HTTP-date Retry-After in _retry_after_seconds Retry-After (RFC 9110) may be either delta-seconds or an HTTP-date. _retry_after_seconds only parsed the numeric form and returned None for HTTP-date values, so the crawler silently ignored server-provided retry timing in that case. Parse the HTTP-date form via email.utils.parsedate_to_datetime, assume UTC for naive datetimes, compute the delay against now, and floor at 1.0 (past/near dates never yield a negative sleep). Empty/unparseable headers still return None. Downstream _sleep_before_retry clamps to the max delay. Add tests covering numeric, future HTTP-date, past HTTP-date, and garbage/empty header cases. Refs jmservera/SquadScope#582 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(press-context): treat no-press sentinel as 0 tokens in gate fallback press_token_estimate() feeds the gate's --press-token-estimate fallback signal. It previously returned a positive count for the non-empty NO_PRESS_SENTINEL text, so if analysis_gate.py ever fell back to the token estimate (missing/unreadable --press-context-path), a press-less week could be misclassified as populated and wrongly trip the stale-press gate. Return 0 when the stripped content matches NO_PRESS_SENTINEL_MARKER (in addition to empty/whitespace), reusing the existing marker regex so the fallback stays consistent with the authoritative path logic (sentinel = not populated). Extend tests to cover sentinel -> 0 -> not populated. Refs jmservera/SquadScope#582 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(crawler): honor server Retry-After above the backoff cap _sleep_before_retry unconditionally capped the delay to RETRY_MAX_DELAY_SECONDS (30s), so a server-supplied Retry-After (e.g. 120s on a 429) only slept 30s and defeated the purpose of honoring the header, risking repeated rate-limit hits. Honor a positive Retry-After up to a new, larger RETRY_AFTER_MAX_SECONDS (120s) ceiling, while the computed exponential backoff+jitter branch stays bounded by RETRY_MAX_DELAY_SECONDS. The separate ceiling still protects against a hostile/absurd Retry-After hanging the crawler. Add tests covering honored, bounded, small, and no-header cases. Refs jmservera/SquadScope#582 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(crawler): reject non-finite numeric Retry-After values float('nan')/float('inf') parse successfully, so a numeric Retry-After header of nan/inf could leak a non-finite value into retry logic and make behavior unpredictable. Guard the numeric branch with math.isfinite: only finite values are honored; nan/inf/-inf fall through to HTTP-date parsing (which returns None for such strings) and thus normal backoff. Add tests for nan/inf/-inf/Infinity -> None. Refs jmservera/SquadScope#582 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: apply ruff format to press-context and crawler tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>