Per-model reasoning profiles, DeepSeek V4 Pro slot, freshness/observability overhaul
MODEL_PROFILES (pipeline/providers.py) is the single source of truth for
model ids, reasoning controls and sampling:
- nemotron-3-ultra: chat_template_kwargs enable_thinking + medium_effort
- nemotron-3-super: reasoning_effort low (default reasoning ate the whole
8192-token budget in live runs and truncated every draft)
- deepseek-ai/deepseek-v4-pro: reasoning_effort high; wired at chain #3
(not in this key's catalog yet - 404 falls through until NVIDIA opens it)
- qwen3.5: enable_thinking + thinking-mode sampling (0.6/0.95/20/0.0)
- glm-5.2: provider-default reasoning, no invented fields on the wire
- mistral-medium-3.5: reasoning_effort high (last fallback, quality first)
- gemini-3.6/3.5-flash: thinkingConfig.thinkingLevel medium, temp 0.1
Reasoning traces are now provably separated from final answers: Gemini
thought-parts and NIM reasoning_content never reach the JSON parser,
published data or logs. Malformed JSON gets ONE no-thinking format-repair
call (syntax only; content failures still fail over per policy). NIM read
timeout raised to 180s (550B models regularly exceeded 120s).
Freshness/observability per the editorial ops spec:
- NEWS_MAX_AGE_HOURS in common.py (default 120, validated 24-336,
warning + safe fallback on bad values) replaces the 48h constant
- future-dated items dropped beyond 6h clock skew, before any AI call
- items without a source date carry dateInferred; the prompt shows them
as first-seen stamps, never as publication times
- articles record sourcePublishedUtc (newest real source date); the site
displays it instead of generation time, with m/d labels for older news
- title-echo summaries no longer count as material (item_has_material)
- dedupe ranks material-bearing groups above title-only ones so thin rows
cannot crowd real stories out of the cap; per-run skip-reason stats
- write logs ai_calls per model; CI scans build output for secret-like
strings and reasoning traces before deploy
Tests: 51 provider payload/parsing checks (all mocked), 121 write checks,
6 dedupe suites incl. freshness window, future guard and material ranking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
完整差異
@@ -49,13 +49,26 @@ jobs:\n AVWIRE_GEMINI_MODEL: ${{ vars.AVWIRE_GEMINI_MODEL }}\n AVWIRE_NVIDIA_MODEL: ${{ vars.AVWIRE_NVIDIA_MODEL }}\n AVWIRE_PROVIDER_ORDER: ${{ vars.AVWIRE_PROVIDER_ORDER }}\n- AVWIRE_MAX_AGE_HOURS: ${{ vars.AVWIRE_MAX_AGE_HOURS }}\n+ # freshness window (hours); code default 120, validated 24-336\n+ NEWS_MAX_AGE_HOURS: ${{ vars.NEWS_MAX_AGE_HOURS }}\n run: |\n python pipeline/fetch.py\n python pipeline/dedupe.py\n python pipeline/write.py\n python pipeline/build.py\n \n+ - name: Scan build output for secrets and reasoning traces\n+ run: |\n+ if grep -rInE "nvapi-[A-Za-z0-9_-]{16,}|AIza[0-9A-Za-z_-]{20,}|sk-ant-[A-Za-z0-9_-]{16,}|Bearer [A-Za-z0-9_./+-]{20,}" site/ ; then\n+ echo "::error::secret-like string found in build output"\n+ exit 1\n+ fi\n+ if grep -rIl "reasoning_content\|<think>" site/ ; then\n+ echo "::error::reasoning trace found in build output"\n+ exit 1\n+ fi\n+ echo "scan: build output clean"\n+\n # Upload BEFORE the data commit so the deploy never depends on git push.\n - uses: actions/upload-pages-artifact@v3\n with:\n@@ -79,7 +79,7 @@ python -m http.server -d site 8000\n | `AVWIRE_MODEL` | `claude-opus-5` | Anthropic 模型 |\n | `AVWIRE_GEMINI_MODEL` | `gemini-2.5-flash` | Gemini 模型 |\n | `AVWIRE_NVIDIA_MODEL` | `deepseek-ai/deepseek-v3.1` | NVIDIA NIM 模型 |\n-| `AVWIRE_MAX_AGE_HOURS` | `48` | 新聞新鮮度窗口(小時);週末官方來源停更時可放寬 |\n+| `NEWS_MAX_AGE_HOURS` | `120` | 新聞新鮮度窗口(小時),驗證範圍 24–336,非法值回退預設並警告 |\n | `AEROAPI_KEY` | — | FlightAware AeroAPI(選用統計) |\n | `AVWIRE_BASE_PATH` | `/avwire` | 站台子路徑(Pages 專案站台) |\n \n@@ -109,6 +109,10 @@ absent when nothing new that hour)\n "zh": {"title": "...", "summary": "<=80字", "body": ["段落1", "段落2"]},\n "en": {"title": "...", "summary": "...", "body": ["para1", "para2"]},\n "sources": [{"name": "FAA — Statement", "url": "https://..."}], // >=1, REQUIRED\n+ "sourcePublishedUtc": "2026-07-23T04:00Z", // OPTIONAL newest source-provided\n+ // publish time (never an inferred\n+ // stamp); the site displays this,\n+ // publishedUtc orders the archive\n "writer": "gemini:gemini-2.5-flash", // OPTIONAL provenance "provider:model";\n // absent on pre-multi-provider articles\n "facts": [ // OPTIONAL machine-verified evidence:\n@@ -309,15 +309,27 @@ def side(own, other):\n image = str(raw["image"]) if raw.get("image") else None\n if image and not _safe_web_url(image):\n image = None\n- tpe = dt.astimezone(TPE)\n+ # Display the SOURCE's newest publication time when the write stage\n+ # recorded one; the generation time (dt) is used for ordering only, so\n+ # a days-old official release is never presented as breaking news.\n+ display_dt = dt\n+ if raw.get("sourcePublishedUtc"):\n+ try:\n+ display_dt = parse_iso(str(raw["sourcePublishedUtc"]))\n+ except (ValueError, TypeError):\n+ pass\n+ tpe = display_dt.astimezone(TPE)\n+ today_tpe = now_utc().astimezone(TPE).date()\n+ time_label = (tpe.strftime("%H:%M") if tpe.date() == today_tpe\n+ else tpe.strftime("%m/%d"))\n return {\n "id": art_id,\n "dt": dt,\n "cat": cat,\n "tag_class": TAGCLS.get(cat, "tag-neutral"),\n "image": image,\n "source": str(raw.get("primarySource") or (sources[0]["name"] if sources else "—")),\n- "time": tpe.strftime("%H:%M"),\n+ "time": time_label,\n "meta_ts": tpe.strftime("%Y-%m-%d %H:%M") + " UTC+8",\n "zh": side(zh, en),\n "en": side(en, zh),\n@@ -42,6 +42,75 @@\n # Articles shown on the home page feed.\n HOME_FEED_LIMIT = 30\n \n+\n+def _parse_max_age(value, default: int = 120,\n+ low: int = 24, high: int = 336) -> int:\n+ """Validate the freshness-window setting (hours).\n+\n+ Non-integers fall back to the default with a warning; values outside\n+ [low, high] are treated as configuration errors and also fall back, so\n+ a bad setting can never admit unbounded-age news.\n+ """\n+ if value in (None, ""):\n+ return default\n+ try:\n+ hours = int(str(value).strip())\n+ except (TypeError, ValueError):\n+ print(f"config: NEWS_MAX_AGE_HOURS={value!r} is not an integer; "\n+ f"using {default}")\n+ return default\n+ if not low <= hours <= high:\n+ print(f"config: NEWS_MAX_AGE_HOURS={hours} outside [{low}, {high}]; "\n+ f"using {default}")\n+ return default\n+ return hours\n+\n+\n+# Freshness window for the dedupe stage. Official sources go quiet on\n+# weekends, so the default reaches back 5 days; seen.json's 21-day memory\n+# still prevents any story from running twice. Single source of truth -\n+# do not read the env var anywhere else. (AVWIRE_MAX_AGE_HOURS is the\n+# deprecated alias.)\n+NEWS_MAX_AGE_HOURS = _parse_max_age(\n+ os.environ.get("NEWS_MAX_AGE_HOURS")\n+ or os.environ.get("AVWIRE_MAX_AGE_HOURS"))\n+\n+# Items stamped this far in the future are treated as source clock/parse\n+# errors and dropped before any AI call; smaller offsets are tolerated as\n+# timezone skew.\n+FUTURE_SKEW_HOURS = 6\n+\n+# --- source-material completeness (editorial spec section 2) -------------\n+# An item contributes usable evidence only when its summary adds real text\n+# beyond the headline; a group needs at least one such item to be worth an\n+# LLM call. Single source of truth for dedupe.py and write.py.\n+MIN_EFFECTIVE_SUMMARY_CHARS = 25\n+\n+_SQUASH_RE = re.compile(r"\s+")\n+\n+\n+def squash_text(text) -> str:\n+ """Whitespace-collapsed, case-folded text for verbatim comparisons."""\n+ return _SQUASH_RE.sub(" ", str(text or "")).strip().casefold()\n+\n+\n+def item_has_material(item: dict) -> bool:\n+ if not isinstance(item, dict):\n+ return False\n+ summary = squash_text(item.get("summary"))\n+ if not summary:\n+ return False\n+ title = squash_text(item.get("title"))\n+ if title and title in summary:\n+ # A summary that merely repeats the headline is not evidence.\n+ summary = summary.replace(title, "", 1)\n+ return len(summary.strip()) >= MIN_EFFECTIVE_SUMMARY_CHARS\n+\n+\n+def group_has_material(group: dict) -> bool:\n+ return any(item_has_material(item)\n+ for item in (group.get("items") or []))\n+\n # Master source registry. `key` is the stable identifier used for\n # data/raw/<key>.json. `fmt` is what we display on the sources page.\n # `endpoint` is what fetch.py actually requests; `kind`:\n@@ -9,15 +9,17 @@\n """\n from __future__ import annotations\n \n-import os\n import re\n from datetime import datetime, timedelta, timezone\n from difflib import SequenceMatcher\n \n from common import (\n DATA_DIR,\n+ FUTURE_SKEW_HOURS,\n+ NEWS_MAX_AGE_HOURS,\n RAW_DIR,\n SOURCES,\n+ group_has_material,\n iso_minute,\n load_json,\n norm_url,\n@@ -28,13 +30,9 @@\n \n MAX_GROUPS = 12 # emitted per run; bounds LLM cost in write.py\n MAX_ITEMS_PER_GROUP = 5\n-# Freshness window. Official sources go quiet on weekends, so the repo\n-# variable AVWIRE_MAX_AGE_HOURS can widen it (e.g. 120) without a deploy;\n-# the 21-day seen.json memory still prevents any story from running twice.\n-try:\n- MAX_ITEM_AGE_HOURS = int(os.environ.get("AVWIRE_MAX_AGE_HOURS") or 48)\n-except ValueError:\n- MAX_ITEM_AGE_HOURS = 48\n+# Freshness window: single source of truth in common.NEWS_MAX_AGE_HOURS\n+# (env NEWS_MAX_AGE_HOURS, validated, default 120).\n+MAX_ITEM_AGE_HOURS = NEWS_MAX_AGE_HOURS\n SEEN_TITLE_DAYS = 21 # only seen.json titles this recent are compared\n SEEN_TITLE_SIM = 0.75 # vs seen.json titles -> drop as already covered\n GROUP_TITLE_SIM = 0.60 # between fresh items -> same story group\n@@ -111,23 +109,31 @@ def _load_seen(now: datetime) -> tuple[set[str], list[str]]:\n \n \n def _is_fresh(\n- item: dict, now: datetime, seen_urls: set[str], seen_titles: list[str]\n+ item: dict, now: datetime, seen_urls: set[str], seen_titles: list[str],\n+ stats: dict,\n ) -> bool:\n title = str(item.get("title") or "").strip()\n if not title:\n+ stats["skipped_untitled"] += 1\n return False\n url = str(item.get("url") or "").strip()\n if url and norm_url(url) in seen_urls:\n+ stats["skipped_seen_url"] += 1\n return False\n published = _published(item)\n- if published is not None and now - published > timedelta(\n- hours=MAX_ITEM_AGE_HOURS\n- ):\n- return False\n+ if published is not None:\n+ if now - published > timedelta(hours=MAX_ITEM_AGE_HOURS):\n+ stats["skipped_too_old"] += 1\n+ return False\n+ if published - now > timedelta(hours=FUTURE_SKEW_HOURS):\n+ # Beyond timezone/parse skew: a source clock error, not news.\n+ stats["skipped_future_date"] += 1\n+ return False\n normalized = norm_title(title)\n if normalized:\n for seen_title in seen_titles:\n if _similar(normalized, seen_title) >= SEEN_TITLE_SIM:\n+ stats["skipped_seen_title"] += 1\n return False\n return True\n \n@@ -188,19 +194,28 @@ def main() -> None:\n now = now_utc()\n raw_items = _load_raw_items()\n seen_urls, seen_titles = _load_seen(now)\n+ stats = {key: 0 for key in (\n+ "skipped_untitled", "skipped_seen_url", "skipped_too_old",\n+ "skipped_future_date", "skipped_seen_title")}\n fresh = [\n item\n for item in raw_items\n- if _is_fresh(item, now, seen_urls, seen_titles)\n+ if _is_fresh(item, now, seen_urls, seen_titles, stats)\n ]\n \n- ranked: list[tuple[bool, datetime, list[dict]]] = []\n+ # Rank: cross-source coverage first, then groups that actually carry\n+ # summary material (title-only groups cannot survive the evidence rules,\n+ # so they must never crowd real stories out of the MAX_GROUPS cap),\n+ # then recency.\n+ ranked: list[tuple[bool, bool, datetime, list[dict]]] = []\n for members in _group(fresh):\n members = _best_first(members)[:MAX_ITEMS_PER_GROUP]\n newest = max((_published(m) or _EPOCH for m in members), default=_EPOCH)\n multi = len({m["sourceKey"] for m in members}) > 1\n- ranked.append((multi, newest, members))\n- ranked.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)\n+ material = group_has_material({"items": members})\n+ ranked.append((multi, material, newest, members))\n+ ranked.sort(key=lambda entry: (entry[0], entry[1], entry[2]), reverse=True)\n+ material_groups = sum(1 for entry in ranked if entry[1])\n \n stamp = now.strftime("%Y%m%d-%H%M")\n payload = {\n@@ -211,7 +226,7 @@ def main() -> None:\n "primarySource": members[0]["source"],\n "items": members,\n }\n- for n, (_multi, _newest, members) in enumerate(\n+ for n, (_multi, _material, _newest, members) in enumerate(\n ranked[:MAX_GROUPS], start=1\n )\n ],\n@@ -221,6 +236,14 @@ def main() -> None:\n f"dedupe: {len(raw_items)} raw items -> {len(fresh)} fresh "\n f"-> {len(payload['groups'])} groups"\n )\n+ print(\n+ "dedupe stats: window={}h sources={} {} "\n+ "groups_with_material={}/{}".format(\n+ MAX_ITEM_AGE_HOURS, len(SOURCES),\n+ " ".join(f"{k}={v}" for k, v in stats.items()),\n+ min(material_groups, MAX_GROUPS), len(payload["groups"]),\n+ )\n+ )\n \n \n if __name__ == "__main__":\n@@ -434,17 +434,25 @@ def _finalize(raw_items: list[dict], fetched_iso: str,\n items = []\n for item in raw_items[:MAX_ITEMS_PER_SOURCE]:\n published = item.get("published")\n+ inferred = False\n if published:\n published_iso = iso_minute(published)\n else:\n+ # No source-provided date: stamp first-seen time, stable across\n+ # fetches, and FLAG it so downstream never presents the stamp\n+ # as a real publication time.\n published_iso = prev_published.get(norm_url(item["url"]), fetched_iso)\n- items.append({\n+ inferred = True\n+ row = {\n "title": _clip(item["title"], TITLE_MAX),\n "url": item["url"],\n "publishedUtc": published_iso,\n "summary": _clip(_collapse(item.get("summary"))),\n "image": item.get("image"),\n- })\n+ }\n+ if inferred:\n+ row["dateInferred"] = True\n+ items.append(row)\n return items\n \n \n@@ -46,7 +46,86 @@\n anthropic = None\n \n DEFAULT_ORDER = "anthropic,gemini,nvidia"\n-HTTP_TIMEOUT = (10, 120) # (connect, read) seconds\n+# 550B-class NIM models regularly need >120s on the free tier.\n+HTTP_TIMEOUT = (10, 180) # (connect, read) seconds\n+\n+# --------------------------------------------------------------------------\n+# Centralized per-model configuration - the single source of truth for\n+# model ids, reasoning controls and sampling. Do NOT scatter these fields\n+# into request builders. Each profile has:\n+# payload / generationConfig extras merged into normal verification calls\n+# repair reasoning overrides for the no-thinking "format repair" call\n+# (JSON syntax only - content failures NEVER take this path)\n+# Only fields the respective endpoint documents are sent; models without a\n+# profile fall back to conservative generic settings.\n+NVIDIA_DEFAULT_MODEL = "nvidia/nemotron-3-ultra-550b-a55b"\n+GEMINI_DEFAULT_MODEL = "gemini-2.5-flash"\n+\n+MODEL_PROFILES: dict = {\n+ "nvidia/nemotron-3-ultra-550b-a55b": {\n+ # Medium thinking via the official Nemotron chat-template kwargs.\n+ "payload": {"temperature": 1.0, "top_p": 0.95,\n+ "chat_template_kwargs": {"enable_thinking": True,\n+ "medium_effort": True}},\n+ "repair": {"chat_template_kwargs": {"enable_thinking": False}},\n+ },\n+ "nvidia/nemotron-3-super-120b-a12b": {\n+ # Low effort via the official top-level field: the default full\n+ # reasoning ate the whole 8192-token budget in live runs.\n+ "payload": {"temperature": 1.0, "top_p": 0.95,\n+ "reasoning_effort": "low"},\n+ "repair": {"reasoning_effort": "none"},\n+ },\n+ "deepseek-ai/deepseek-v4-pro": {\n+ "payload": {"temperature": 1.0, "top_p": 0.95,\n+ "reasoning_effort": "high"},\n+ "repair": {"reasoning_effort": "none"},\n+ },\n+ "qwen/qwen3.5-397b-a17b": {\n+ # NIM exposes thinking on/off only for Qwen; thinking-mode sampling\n+ # per the model card.\n+ "payload": {"temperature": 0.6, "top_p": 0.95, "top_k": 20,\n+ "presence_penalty": 0.0,\n+ "chat_template_kwargs": {"enable_thinking": True}},\n+ "repair": {"chat_template_kwargs": {"enable_thinking": False}},\n+ },\n+ "z-ai/glm-5.2": {\n+ # The public NIM schema for GLM exposes no reasoning control;\n+ # provider-default reasoning is used deliberately. The marker below\n+ # is internal documentation and is never sent on the wire.\n+ "reasoningMode": "provider_default",\n+ "payload": {"temperature": 0.1},\n+ "repair": {},\n+ },\n+ "mistralai/mistral-medium-3.5-128b": {\n+ # Last fallback: rarely called, quality over latency -> high.\n+ # NIM offers none|high only for this model.\n+ "payload": {"temperature": 0.7, "reasoning_effort": "high"},\n+ "repair": {"reasoning_effort": "none"},\n+ },\n+ "gemini-3.6-flash": {\n+ "generationConfig": {"temperature": 0.1, "maxOutputTokens": 8192,\n+ "thinkingConfig": {"thinkingLevel": "medium"}},\n+ "repairGenerationConfig": {\n+ "temperature": 0.1, "maxOutputTokens": 8192,\n+ "thinkingConfig": {"thinkingLevel": "minimal"}},\n+ },\n+ "gemini-3.5-flash": {\n+ "generationConfig": {"temperature": 0.1, "maxOutputTokens": 8192,\n+ "thinkingConfig": {"thinkingLevel": "medium"}},\n+ "repairGenerationConfig": {\n+ "temperature": 0.1, "maxOutputTokens": 8192,\n+ "thinkingConfig": {"thinkingLevel": "minimal"}},\n+ },\n+}\n+\n+REPAIR_SYSTEM_PROMPT = (\n+ "You are a JSON syntax fixer. The user message is a single JSON object "\n+ "with broken syntax (quoting, escaping, commas, brackets or field "\n+ "arrangement). Return ONLY the corrected JSON object. Do not add, "\n+ "remove, reorder facts or change ANY content, field name or value - "\n+ "repair the syntax only."\n+)\n \n _THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)\n _FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$")\n@@ -156,30 +235,38 @@ class GeminiProvider:\n \n def __init__(self, model=None) -> None:\n self.model = (model or os.environ.get("AVWIRE_GEMINI_MODEL")\n- or "gemini-2.5-flash")\n+ or GEMINI_DEFAULT_MODEL)\n self.label = f"{self.name}:{self.model}"\n \n def available(self) -> bool:\n return bool(os.environ.get("GEMINI_API_KEY"))\n \n- def draft(self, system_prompt: str, user_prompt: str, schema: dict):\n+ def _generation_config(self, schema: dict, repair: bool) -> dict:\n+ profile = MODEL_PROFILES.get(self.model, {})\n+ key = "repairGenerationConfig" if repair else "generationConfig"\n+ config = dict(profile.get(key) or {})\n+ config["responseMimeType"] = "application/json"\n+ config["responseJsonSchema"] = schema\n+ return config\n+\n+ def _post(self, payload: dict):\n url = ("https://generativelanguage.googleapis.com/v1beta/models/"\n f"{self.model}:generateContent")\n- payload = {\n- "systemInstruction": {"parts": [{"text": system_prompt}]},\n- "contents": [{"role": "user", "parts": [{"text": user_prompt}]}],\n- "generationConfig": {\n- "responseMimeType": "application/json",\n- "responseJsonSchema": schema,\n- },\n- }\n try:\n- response = requests.post(\n+ return requests.post(\n url, json=payload, timeout=HTTP_TIMEOUT,\n headers={"x-goog-api-key": os.environ["GEMINI_API_KEY"]},\n )\n except requests.RequestException as exc:\n raise ProviderError(type(exc).__name__) from exc\n+\n+ def draft(self, system_prompt: str, user_prompt: str, schema: dict):\n+ payload = {\n+ "systemInstruction": {"parts": [{"text": system_prompt}]},\n+ "contents": [{"role": "user", "parts": [{"text": user_prompt}]}],\n+ "generationConfig": self._generation_config(schema, repair=False),\n+ }\n+ response = self._post(payload)\n if response.status_code in (401, 403):\n raise ProviderAuthError(f"HTTP {response.status_code}")\n if response.status_code == 400 and (\n@@ -197,6 +284,19 @@ def draft(self, system_prompt: str, user_prompt: str, schema: dict):\n if response.status_code >= 400:\n raise ProviderError(\n f"HTTP {response.status_code}: {_body_snippet(response)}")\n+ text = self._final_text(response)\n+ if text is None:\n+ return None\n+ try:\n+ return extract_json(text)\n+ except ValueError as exc:\n+ return self._repair(text, schema, exc)\n+\n+ def _final_text(self, response):\n+ """Final-answer text only. Returns None for genuine content blocks;\n+ raises ProviderError on technical failures. Thought parts (the\n+ reasoning trace) are excluded and never reach the JSON parser, the\n+ published data or the logs."""\n data = response.json()\n feedback = data.get("promptFeedback") or {}\n if feedback.get("blockReason"):\n@@ -217,46 +317,72 @@ def draft(self, system_prompt: str, user_prompt: str, schema: dict):\n # the failover loop try the next provider.\n raise ProviderError(f"finishReason {finish}")\n parts = (candidate.get("content") or {}).get("parts") or []\n- text = "".join(p.get("text", "") for p in parts)\n+ text = "".join(\n+ p.get("text", "") for p in parts\n+ if isinstance(p, dict) and not p.get("thought"))\n if not text.strip():\n raise ProviderError("empty response text")\n+ return text\n+\n+ def _repair(self, broken: str, schema: dict, exc) -> dict:\n+ """One minimal-thinking retry that fixes JSON SYNTAX only.\n+\n+ Content problems (missing quotes, unsupported claims) never take\n+ this path - they surface after parsing and are handled by the\n+ failover policy in write.py.\n+ """\n+ print(f"write: {self.label} returned malformed JSON ({exc}); "\n+ "attempting format repair")\n+ payload = {\n+ "systemInstruction": {"parts": [{"text": REPAIR_SYSTEM_PROMPT}]},\n+ "contents": [{"role": "user", "parts": [{"text": broken}]}],\n+ "generationConfig": self._generation_config(schema, repair=True),\n+ }\n+ response = self._post(payload)\n+ if response.status_code >= 400:\n+ raise ProviderError(\n+ f"format repair HTTP {response.status_code}")\n+ text = self._final_text(response)\n+ if not text:\n+ raise ProviderError("format repair returned no text")\n try:\n return extract_json(text)\n- except ValueError as exc:\n- raise ProviderError(f"bad JSON from model: {exc}") from exc\n+ except ValueError as repair_exc:\n+ raise ProviderError(\n+ f"bad JSON even after format repair: {repair_exc}"\n+ ) from repair_exc\n \n \n class NvidiaProvider:\n name = "nvidia"\n \n def __init__(self, model=None) -> None:\n self.model = (model or os.environ.get("AVWIRE_NVIDIA_MODEL")\n- or "deepseek-ai/deepseek-v3.1")\n+ or NVIDIA_DEFAULT_MODEL)\n self.label = f"{self.name}:{self.model}"\n \n def available(self) -> bool:\n return bool(os.environ.get("NVIDIA_API_KEY"))\n \n- def draft(self, system_prompt: str, user_prompt: str, schema: dict):\n- # No guided decoding on NIM across all models: enforce JSON by prompt\n- # and recover it with extract_json; the caller validates the shape.\n- instruction = (\n- "\n\nReturn ONLY one JSON object (no markdown fences, no prose) "\n- "conforming exactly to this JSON Schema:\n"\n- + json.dumps(schema, ensure_ascii=False)\n- )\n+ def _payload_extras(self, repair: bool) -> dict:\n+ profile = MODEL_PROFILES.get(self.model, {})\n+ extras = dict(profile.get("payload") or {"temperature": 0.2})\n+ if repair:\n+ # Repair overrides replace the reasoning control wholesale;\n+ # sampling stays as configured for the model.\n+ extras.update(profile.get("repair") or {})\n+ return extras\n+\n+ def _post(self, messages: list, repair: bool):\n payload = {\n "model": self.model,\n- "messages": [\n- {"role": "system", "content": system_prompt},\n- {"role": "user", "content": user_prompt + instruction},\n- ],\n- "temperature": 0.2,\n+ "messages": messages,\n "max_tokens": 8192,\n "stream": False,\n+ **self._payload_extras(repair),\n }\n try:\n- response = requests.post(\n+ return requests.post(\n "https://integrate.api.nvidia.com/v1/chat/completions",\n json=payload, timeout=HTTP_TIMEOUT,\n headers={\n@@ -267,18 +393,11 @@ def draft(self, system_prompt: str, user_prompt: str, schema: dict):\n )\n except requests.RequestException as exc:\n raise ProviderError(type(exc).__name__) from exc\n- if response.status_code in (401, 403):\n- raise ProviderAuthError(f"HTTP {response.status_code}")\n- if response.status_code in (402, 429):\n- raise ProviderQuotaError(\n- f"HTTP {response.status_code} (credits exhausted?)")\n- if response.status_code == 404:\n- raise ProviderError(\n- f"model not found: {self.model} "\n- "(set repo variable AVWIRE_NVIDIA_MODEL)")\n- if response.status_code >= 400:\n- raise ProviderError(\n- f"HTTP {response.status_code}: {_body_snippet(response)}")\n+\n+ def _final_text(self, response):\n+ """Final-answer text only; message.reasoning_content (the reasoning\n+ trace some NIM models emit) is never read, parsed, published or\n+ logged. Returns None for content blocks; raises on failures."""\n data = response.json()\n choices = data.get("choices") or []\n if not choices:\n@@ -295,10 +414,64 @@ def draft(self, system_prompt: str, user_prompt: str, schema: dict):\n text = (choice.get("message") or {}).get("content") or ""\n if not text.strip():\n raise ProviderError("empty response content")\n+ return text\n+\n+ def _check_status(self, response) -> None:\n+ if response.status_code in (401, 403):\n+ raise ProviderAuthError(f"HTTP {response.status_code}")\n+ if response.status_code in (402, 429):\n+ raise ProviderQuotaError(\n+ f"HTTP {response.status_code} (credits exhausted?)")\n+ if response.status_code == 404:\n+ raise ProviderError(\n+ f"model not found: {self.model} "\n+ "(not in this key's catalog yet?)")\n+ if response.status_code >= 400:\n+ raise ProviderError(\n+ f"HTTP {response.status_code}: {_body_snippet(response)}")\n+\n+ def draft(self, system_prompt: str, user_prompt: str, schema: dict):\n+ # No guided decoding on NIM across all models: enforce JSON by prompt\n+ # and recover it with extract_json; the caller validates the shape.\n+ instruction = (\n+ "\n\nReturn ONLY one JSON object (no markdown fences, no prose) "\n+ "conforming exactly to this JSON Schema:\n"\n+ + json.dumps(schema, ensure_ascii=False)\n+ )\n+ response = self._post(\n+ [{"role": "system", "content": system_prompt},\n+ {"role": "user", "content": user_prompt + instruction}],\n+ repair=False,\n+ )\n+ self._check_status(response)\n+ text = self._final_text(response)\n+ if text is None:\n+ return None\n try:\n return extract_json(text)\n except ValueError as exc:\n- raise ProviderError(f"bad JSON from model: {exc}") from exc\n+ return self._repair(text, exc)\n+\n+ def _repair(self, broken: str, exc) -> dict:\n+ """One no-thinking retry that fixes JSON SYNTAX only; content\n+ failures never take this path (see GeminiProvider._repair)."""\n+ print(f"write: {self.label} returned malformed JSON ({exc}); "\n+ "attempting format repair")\n+ response = self._post(\n+ [{"role": "system", "content": REPAIR_SYSTEM_PROMPT},\n+ {"role": "user", "content": broken}],\n+ repair=True,\n+ )\n+ self._check_status(response)\n+ text = self._final_text(response)\n+ if not text:\n+ raise ProviderError("format repair returned no text")\n+ try:\n+ return extract_json(text)\n+ except ValueError as repair_exc:\n+ raise ProviderError(\n+ f"bad JSON even after format repair: {repair_exc}"\n+ ) from repair_exc\n \n \n _REGISTRY = {\n@@ -35,13 +35,15 @@\n DATA_DIR,\n MAX_FLASHES,\n RAW_DIR,\n+ group_has_material,\n iso_minute,\n load_json,\n norm_url,\n now_utc,\n parse_iso,\n save_json,\n slugify,\n+ squash_text,\n )\n from providers import (\n ProviderAuthError,\n@@ -362,7 +364,12 @@ def group_prompt(group: dict) -> str:\n for idx, item in enumerate(group.get("items", []), 1):\n title = str(item.get("title") or "")[:300] # defensive prompt-cost cap\n lines.append(f"{idx}. [{item.get('source', '?')}] {title}")\n- lines.append(f" published: {item.get('publishedUtc', '?')}")\n+ if item.get("dateInferred"):\n+ # Never present our first-seen stamp as a publication time.\n+ lines.append(f" first seen: {item.get('publishedUtc', '?')} "\n+ "(the source provides no publish date)")\n+ else:\n+ lines.append(f" published: {item.get('publishedUtc', '?')}")\n summary = str(item.get("summary") or "").strip()\n if summary:\n lines.append(f" summary: {summary}")\n@@ -469,26 +476,17 @@ def validate_draft(draft):\n return None\n \n \n-MIN_SUMMARY_CHARS = 30\n-\n-\n-def has_material(group: dict) -> bool:\n- """Code-level source-completeness check (editorial spec section 2).\n-\n- A group whose items carry no real summary text (title-only RSS rows)\n- cannot survive the evidence rules, so it is consumed without spending\n- an API call.\n- """\n- return any(\n- len(_squash(item.get("summary") or "")) >= MIN_SUMMARY_CHARS\n- for item in group.get("items", []) if isinstance(item, dict))\n+# Code-level source-completeness check (editorial spec section 2): groups\n+# without real summary text are consumed without spending an API call.\n+# Single source of truth lives in common.group_has_material.\n+has_material = group_has_material\n \n \n class DraftInvalid(Exception):\n """Draft failed validation or quote verification on every allowed try."""\n \n \n-def _validated_draft(provider, group: dict, tries: int):\n+def _validated_draft(provider, group: dict, tries: int, ai_calls=None):\n """Call `provider` up to `tries` times until a draft survives validation\n and quote verification.\n \n@@ -498,6 +496,8 @@ def _validated_draft(provider, group: dict, tries: int):\n policy retries validation failures, never transport failures.\n """\n for attempt in range(tries):\n+ if ai_calls is not None:\n+ ai_calls[provider.label] = ai_calls.get(provider.label, 0) + 1\n candidate = draft_group(provider, group)\n if candidate is None:\n return None, None\n@@ -516,11 +516,7 @@ def _validated_draft(provider, group: dict, tries: int):\n raise DraftInvalid\n \n \n-_WS_RE = re.compile(r"\s+")\n-\n-\n-def _squash(text) -> str:\n- return _WS_RE.sub(" ", str(text)).strip().casefold()\n+_squash = squash_text # shared with common.item_has_material\n \n \n def verify_facts(draft: dict, group: dict, label: str) -> list:\n@@ -594,6 +590,19 @@ def build_article(draft: dict, group: dict, now, used_ids: set, writer=None,\n "en": draft.get("en", {}),\n "sources": sources,\n }\n+ source_dates = []\n+ for item in items:\n+ if item.get("dateInferred"):\n+ continue\n+ try:\n+ source_dates.append(parse_iso(str(item.get("publishedUtc"))))\n+ except (TypeError, ValueError):\n+ continue\n+ if source_dates:\n+ # The source's own newest publication time - the site displays this,\n+ # never the generation time, so multi-day-old releases are not\n+ # presented as breaking news.\n+ article["sourcePublishedUtc"] = iso_minute(max(source_dates))\n if writer:\n article["writer"] = writer # provenance: "provider:model"\n if facts is not None:\n@@ -776,6 +785,7 @@ def main() -> None:\n queued_entries, queued_groups, queued_ids = [], [], set()\n rejected_groups, rejected_ids = [], set()\n dead_auth: set = set()\n+ ai_calls: dict = {}\n skipped = 0\n \n # --- 1. publish entries a human approved in data/review.json ---------\n@@ -839,7 +849,7 @@ def main() -> None:\n tries = 2 if provider is providers[0] else 1\n try:\n candidate, facts = _validated_draft(provider, group,\n- tries)\n+ tries, ai_calls)\n except DraftInvalid:\n continue # try the next provider in the chain\n except ProviderAuthError as exc:\n@@ -948,6 +958,9 @@ def main() -> None:\n f"{len(queued_entries)} for review, rejected "\n f"{len(rejected_groups)}, skipped {skipped}, "\n f"pending {remaining_count}")\n+ calls_note = " ".join(f"{label}={n}" for label, n in ai_calls.items()) \\n+ or "none"\n+ print(f"write stats: ai_calls {calls_note}")\n if providers and dead_auth and dead_auth == {p.name for p in providers}:\n # Every configured platform failed authentication: turn the Actions\n # run red instead of silently green.\n@@ -21,8 +21,8 @@\n {\n "title": "FAA archives old advisory circular",\n "url": "https://www.faa.gov/newsroom/old-advisory",\n- "publishedUtc": "{{H-72}}",\n- "summary": "Stale item, three days old; must be dropped.",\n+ "publishedUtc": "{{H-130}}",\n+ "summary": "Stale item, older than the 120h window; must be dropped.",\n "image": null\n },\n {\n@@ -5,9 +5,10 @@\n py tests\\test_dedupe.py\n \n Fixtures under tests/fixtures/dedupe/ use {{H-n}} / {{D-n}} placeholders\n-(n hours / days before now) so the 48-hour and 21-day windows never go\n-stale. Each test materializes the fixtures into a temp AVWIRE_DATA_DIR\n-and runs dedupe.py as a subprocess, never touching the real data/ dir.\n+(n hours / days before now) so the freshness (default 120 h) and 21-day\n+windows never go stale. Each test materializes the fixtures into a temp\n+AVWIRE_DATA_DIR and runs dedupe.py as a subprocess, never touching the\n+real data/ dir.\n """\n from __future__ import annotations\n \n@@ -51,8 +52,12 @@ def _materialize_fixtures(data_dir: Path) -> None:\n dst.write_text(_render(src.read_text(encoding="utf-8")), encoding="utf-8")\n \n \n-def _run_dedupe(data_dir: Path) -> str:\n+def _run_dedupe(data_dir: Path, extra_env: dict | None = None) -> str:\n env = dict(os.environ, AVWIRE_DATA_DIR=str(data_dir))\n+ env.pop("NEWS_MAX_AGE_HOURS", None)\n+ env.pop("AVWIRE_MAX_AGE_HOURS", None)\n+ if extra_env:\n+ env.update(extra_env)\n proc = subprocess.run(\n [sys.executable, str(DEDUPE_SCRIPT)],\n cwd=REPO,\n@@ -66,6 +71,15 @@ def _run_dedupe(data_dir: Path) -> str:\n return proc.stdout.strip()\n \n \n+def _summary_line(stdout: str) -> str:\n+ """First stdout line: 'dedupe: N raw items -> N fresh -> N groups'.\n+ (Config warnings may precede it; the stats line follows it.)"""\n+ for line in stdout.splitlines():\n+ if line.startswith("dedupe:"):\n+ return line\n+ return stdout\n+\n+\n def _load_pending(data_dir: Path) -> dict:\n with open(data_dir / "pending.json", encoding="utf-8") as fh:\n return json.load(fh)\n@@ -95,7 +109,13 @@ def test_filtering_grouping_and_ranking() -> None:\n # 15 raw (easa ok=false items are carried forward, not skipped)\n # -> 11 fresh (dropped: seen URL, seen title, stale, empty title)\n # -> 5 groups.\n- assert stdout == "dedupe: 15 raw items -> 11 fresh -> 5 groups", stdout\n+ assert _summary_line(stdout) == \\n+ "dedupe: 15 raw items -> 11 fresh -> 5 groups", stdout\n+ assert "skipped_too_old=1" in stdout, stdout\n+ assert "skipped_seen_url=1" in stdout, stdout\n+ assert "skipped_seen_title=1" in stdout, stdout\n+ assert "skipped_untitled=1" in stdout, stdout\n+ assert "window=120h" in stdout, stdout\n \n # dedupe must never write seen.json.\n assert (data_dir / "seen.json").read_bytes() == seen_before\n@@ -114,7 +134,7 @@ def test_filtering_grouping_and_ranking() -> None:\n all_titles = [i["title"] for g in groups for i in g["items"]]\n for gone in (\n "FAA statement on winter operations", # seen URL\n- "FAA archives old advisory circular", # stale (72 h)\n+ "FAA archives old advisory circular", # stale (130 h)\n "Boeing delivers 100th 787 to Emirates!", # seen title, 2 days ago\n " ", # empty title\n ):\n@@ -207,7 +227,8 @@ def test_group_cap_and_recency_order() -> None:\n )\n # No seen.json at all: dedupe must cope with it missing.\n stdout = _run_dedupe(data_dir)\n- assert stdout == "dedupe: 15 raw items -> 15 fresh -> 12 groups", stdout\n+ assert _summary_line(stdout) == \\n+ "dedupe: 15 raw items -> 15 fresh -> 12 groups", stdout\n \n groups = _load_pending(data_dir)["groups"]\n assert len(groups) == dedupe.MAX_GROUPS == 12\n@@ -221,17 +242,100 @@ def test_empty_data_dir() -> None:\n with tempfile.TemporaryDirectory(prefix="avwire-dedupe-") as tmp:\n data_dir = Path(tmp)\n stdout = _run_dedupe(data_dir)\n- assert stdout == "dedupe: 0 raw items -> 0 fresh -> 0 groups", stdout\n+ assert _summary_line(stdout) == \\n+ "dedupe: 0 raw items -> 0 fresh -> 0 groups", stdout\n pending = _load_pending(data_dir)\n assert pending["groups"] == []\n \n \n+def _write_snapshot(data_dir: Path, key: str, items: list) -> None:\n+ raw_dir = data_dir / "raw"\n+ raw_dir.mkdir(parents=True, exist_ok=True)\n+ (raw_dir / f"{key}.json").write_text(\n+ json.dumps({"source": key, "fetchedUtc": _stamp(hours=0), "ok": True,\n+ "error": None, "items": items}, ensure_ascii=False),\n+ encoding="utf-8")\n+\n+\n+def _mk_item(title: str, url: str, hours_ago: float, summary: str) -> dict:\n+ dt = datetime.now(timezone.utc) - timedelta(hours=hours_ago)\n+ return {"title": title, "url": url,\n+ "publishedUtc": dt.strftime("%Y-%m-%dT%H:%MZ"),\n+ "summary": summary, "image": None}\n+\n+\n+def test_freshness_window_and_future_guard() -> None:\n+ """120h default window (env-tunable, validated) + future-date guard."""\n+ body = "A perfectly usable summary sentence with plenty of real text."\n+ items = [\n+ _mk_item("Future item within clock skew", "https://www.faa.gov/n/f2",\n+ -2, body), # +2h -> tolerated\n+ _mk_item("Future item beyond clock skew", "https://www.faa.gov/n/f8",\n+ -8, body), # +8h -> source clock error, dropped\n+ _mk_item("Hundred hour old official notice", "https://www.faa.gov/n/o1",\n+ 100, body), # kept by the 120h default\n+ _mk_item("Ancient item far outside window", "https://www.faa.gov/n/o2",\n+ 130, body), # dropped\n+ ]\n+ with tempfile.TemporaryDirectory(prefix="avwire-dedupe-") as tmp:\n+ data_dir = Path(tmp)\n+ _write_snapshot(data_dir, "faa", items)\n+\n+ stdout = _run_dedupe(data_dir)\n+ assert _summary_line(stdout) == \\n+ "dedupe: 4 raw items -> 2 fresh -> 2 groups", stdout\n+ assert "skipped_future_date=1" in stdout, stdout\n+ assert "skipped_too_old=1" in stdout, stdout\n+ titles = [g["items"][0]["title"]\n+ for g in _load_pending(data_dir)["groups"]]\n+ assert "Future item within clock skew" in titles\n+ assert "Hundred hour old official notice" in titles\n+\n+ # Narrower window via env: the 100h item now falls out too.\n+ stdout = _run_dedupe(data_dir, {"NEWS_MAX_AGE_HOURS": "72"})\n+ assert _summary_line(stdout) == \\n+ "dedupe: 4 raw items -> 1 fresh -> 1 groups", stdout\n+ assert "window=72h" in stdout, stdout\n+\n+ # Invalid values fall back to the safe default with a warning.\n+ for bad in ("abc", "12", "999"):\n+ stdout = _run_dedupe(data_dir, {"NEWS_MAX_AGE_HOURS": bad})\n+ assert "config: NEWS_MAX_AGE_HOURS" in stdout, (bad, stdout)\n+ assert "window=120h" in stdout, (bad, stdout)\n+\n+\n+def test_material_groups_rank_first() -> None:\n+ """Title-only groups must not crowd real stories out of the cap."""\n+ body = "A real summary carrying enough evidence text for the model."\n+ items = [\n+ _mk_item("Newest but title only headline item",\n+ "https://www.faa.gov/n/thin", 1, ""),\n+ _mk_item("Older official notice with a full summary",\n+ "https://www.faa.gov/n/full", 30, body),\n+ _mk_item("Summary that just repeats its own headline",\n+ "https://www.faa.gov/n/echo", 2,\n+ "Summary that just repeats its own headline"),\n+ ]\n+ with tempfile.TemporaryDirectory(prefix="avwire-dedupe-") as tmp:\n+ data_dir = Path(tmp)\n+ _write_snapshot(data_dir, "faa", items)\n+ stdout = _run_dedupe(data_dir)\n+ assert _summary_line(stdout) == \\n+ "dedupe: 3 raw items -> 3 fresh -> 3 groups", stdout\n+ groups = _load_pending(data_dir)["groups"]\n+ assert groups[0]["items"][0]["title"] == \\n+ "Older official notice with a full summary", groups\n+ assert "groups_with_material=1/3" in stdout, stdout\n+\n+\n def main() -> None:\n tests = [\n test_norm_title_and_tiebreak,\n test_filtering_grouping_and_ranking,\n test_group_cap_and_recency_order,\n test_empty_data_dir,\n+ test_freshness_window_and_future_guard,\n+ test_material_groups_rank_first,\n ]\n for test in tests:\n test()\n@@ -0,0 +1,282 @@\n+"""Offline tests for pipeline/providers.py request payloads and parsing.\n+\n+Run from the repo root: py tests\\test_providers_payloads.py\n+\n+requests.post is monkeypatched to capture exactly what each model profile\n+sends and to feed canned responses back - no network, no API keys, no\n+NVIDIA/Gemini quota. Verifies:\n+ - per-model reasoning/sampling fields (MODEL_PROFILES)\n+ - the no-thinking format-repair path (JSON syntax only)\n+ - reasoning traces (reasoning_content / thought parts) never reach the\n+ JSON parser or the returned draft\n+"""\n+from __future__ import annotations\n+\n+import json\n+import os\n+import sys\n+from pathlib import Path\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+os.environ.setdefault("NVIDIA_API_KEY", "test-key-not-real")\n+os.environ.setdefault("GEMINI_API_KEY", "test-key-not-real")\n+\n+import providers # noqa: E402\n+\n+_pass = 0\n+_fail = 0\n+\n+\n+def check(cond, label):\n+ global _pass, _fail\n+ if cond:\n+ _pass += 1\n+ else:\n+ _fail += 1\n+ print(f"FAIL: {label}")\n+\n+\n+class FakeResponse:\n+ def __init__(self, payload, status=200):\n+ self.status_code = status\n+ self._payload = payload\n+ self.text = json.dumps(payload)\n+\n+ def json(self):\n+ return self._payload\n+\n+\n+def nvidia_ok(content, reasoning="LEAKED-REASONING-TRACE {not json"):\n+ return FakeResponse({"choices": [{\n+ "finish_reason": "stop",\n+ "message": {"content": content, "reasoning_content": reasoning},\n+ }]})\n+\n+\n+def gemini_ok(parts):\n+ return FakeResponse({"candidates": [{\n+ "finishReason": "STOP", "content": {"parts": parts},\n+ }]})\n+\n+\n+def with_post(fake_post, fn):\n+ original = providers.requests.post\n+ providers.requests.post = fake_post\n+ try:\n+ return fn()\n+ finally:\n+ providers.requests.post = original\n+\n+\n+SCHEMA = {"type": "object"}\n+GOOD = '{"ok": 1}'\n+\n+\n+def test_nvidia_model_profiles():\n+ """Each NIM model sends exactly its documented reasoning controls."""\n+ reasoning_keys = ("reasoning_effort", "chat_template_kwargs", "thinking",\n+ "enable_thinking", "thinking_level", "reasoningMode")\n+ cases = {\n+ "nvidia/nemotron-3-ultra-550b-a55b": dict(\n+ ctk={"enable_thinking": True, "medium_effort": True},\n+ temperature=1.0, top_p=0.95),\n+ "nvidia/nemotron-3-super-120b-a12b": dict(\n+ effort="low", temperature=1.0, top_p=0.95),\n+ "deepseek-ai/deepseek-v4-pro": dict(\n+ effort="high", temperature=1.0, top_p=0.95),\n+ "qwen/qwen3.5-397b-a17b": dict(\n+ ctk={"enable_thinking": True}, temperature=0.6, top_p=0.95,\n+ top_k=20, presence_penalty=0.0),\n+ "z-ai/glm-5.2": dict(temperature=0.1), # provider-default reasoning\n+ "mistralai/mistral-medium-3.5-128b": dict(\n+ effort="high", temperature=0.7),\n+ }\n+ for model, want in cases.items():\n+ captured = []\n+\n+ def fake_post(url, json=None, timeout=None, headers=None):\n+ captured.append(json)\n+ return nvidia_ok(GOOD)\n+\n+ provider = providers.NvidiaProvider(model)\n+ result = with_post(fake_post,\n+ lambda: provider.draft("sys", "user", SCHEMA))\n+ check(result == {"ok": 1}, f"{model}: draft parsed")\n+ p = captured[0]\n+ check(p["model"] == model and p["max_tokens"] == 8192\n+ and p["stream"] is False, f"{model}: base payload")\n+ check(p.get("temperature") == want["temperature"],\n+ f"{model}: temperature {p.get('temperature')}")\n+ if "top_p" in want:\n+ check(p.get("top_p") == want["top_p"], f"{model}: top_p")\n+ if "top_k" in want:\n+ check(p.get("top_k") == want["top_k"]\n+ and p.get("presence_penalty") == want["presence_penalty"],\n+ f"{model}: qwen thinking-mode sampling")\n+ if "ctk" in want:\n+ check(p.get("chat_template_kwargs") == want["ctk"]\n+ and "reasoning_effort" not in p,\n+ f"{model}: chat_template_kwargs {p.get('chat_template_kwargs')}")\n+ elif "effort" in want:\n+ check(p.get("reasoning_effort") == want["effort"]\n+ and "chat_template_kwargs" not in p,\n+ f"{model}: reasoning_effort {p.get('reasoning_effort')}")\n+ else: # GLM: no reasoning field of any kind on the wire\n+ check(all(k not in p for k in reasoning_keys),\n+ f"{model}: no invented reasoning fields, got {sorted(p)}")\n+ print("test_nvidia_model_profiles: done")\n+\n+\n+def test_nvidia_reasoning_trace_never_parsed():\n+ """reasoning_content is ignored; empty final content is a failure."""\n+ def fake_post(url, json=None, timeout=None, headers=None):\n+ return nvidia_ok(GOOD, reasoning='{"leak": true}')\n+\n+ provider = providers.NvidiaProvider("nvidia/nemotron-3-ultra-550b-a55b")\n+ result = with_post(fake_post,\n+ lambda: provider.draft("sys", "user", SCHEMA))\n+ check(result == {"ok": 1} and "leak" not in result,\n+ "final content parsed, reasoning trace ignored")\n+\n+ def fake_post_empty(url, json=None, timeout=None, headers=None):\n+ return nvidia_ok("", reasoning=GOOD)\n+\n+ try:\n+ with_post(fake_post_empty,\n+ lambda: provider.draft("sys", "user", SCHEMA))\n+ check(False, "empty final content must raise, not fall back to trace")\n+ except providers.ProviderError:\n+ check(True, "empty final content raises ProviderError")\n+ print("test_nvidia_reasoning_trace_never_parsed: done")\n+\n+\n+def test_nvidia_format_repair():\n+ """Broken JSON syntax triggers ONE no-thinking repair call."""\n+ calls = []\n+\n+ def fake_post(url, json=None, timeout=None, headers=None):\n+ calls.append(json)\n+ if len(calls) == 1:\n+ return nvidia_ok('{"ok": 1,,}') # syntax error\n+ return nvidia_ok(GOOD)\n+\n+ provider = providers.NvidiaProvider("nvidia/nemotron-3-super-120b-a12b")\n+ result = with_post(fake_post,\n+ lambda: provider.draft("sys", "user", SCHEMA))\n+ check(result == {"ok": 1}, "repair produced a parseable draft")\n+ check(len(calls) == 2, f"exactly one repair call, got {len(calls)}")\n+ repair = calls[1]\n+ check(repair["messages"][0]["content"] == providers.REPAIR_SYSTEM_PROMPT,\n+ "repair uses the syntax-only system prompt")\n+ check(repair.get("reasoning_effort") == "none",\n+ f"repair disables reasoning, got {repair.get('reasoning_effort')}")\n+ check(calls[0].get("reasoning_effort") == "low",\n+ "normal call keeps its configured reasoning")\n+ print("test_nvidia_format_repair: done")\n+\n+\n+def test_gemini_model_profiles():\n+ """Gemini 3.6/3.5 send thinkingLevel medium; unknown models send none."""\n+ for model in ("gemini-3.6-flash", "gemini-3.5-flash"):\n+ captured = []\n+\n+ def fake_post(url, json=None, timeout=None, headers=None):\n+ captured.append(json)\n+ return gemini_ok([{"text": GOOD}])\n+\n+ provider = providers.GeminiProvider(model)\n+ result = with_post(fake_post,\n+ lambda: provider.draft("sys", "user", SCHEMA))\n+ check(result == {"ok": 1}, f"{model}: draft parsed")\n+ config = captured[0]["generationConfig"]\n+ check(config.get("thinkingConfig") == {"thinkingLevel": "medium"},\n+ f"{model}: thinkingLevel medium")\n+ check(config.get("temperature") == 0.1\n+ and config.get("maxOutputTokens") == 8192,\n+ f"{model}: sampling per profile")\n+ check(config.get("responseMimeType") == "application/json"\n+ and config.get("responseJsonSchema") == SCHEMA,\n+ f"{model}: structured output config kept")\n+\n+ captured = []\n+\n+ def fake_post(url, json=None, timeout=None, headers=None):\n+ captured.append(json)\n+ return gemini_ok([{"text": GOOD}])\n+\n+ provider = providers.GeminiProvider("gemini-2.5-flash")\n+ with_post(fake_post, lambda: provider.draft("sys", "user", SCHEMA))\n+ check("thinkingConfig" not in captured[0]["generationConfig"],\n+ "unprofiled Gemini model sends no thinkingConfig")\n+ print("test_gemini_model_profiles: done")\n+\n+\n+def test_gemini_thought_parts_excluded():\n+ """Thought parts (reasoning trace) never reach the parser or output."""\n+ def fake_post(url, json=None, timeout=None, headers=None):\n+ return gemini_ok([\n+ {"thought": True, "text": '{"leak": "reasoning JSON"}'},\n+ {"text": GOOD},\n+ ])\n+\n+ provider = providers.GeminiProvider("gemini-3.6-flash")\n+ result = with_post(fake_post,\n+ lambda: provider.draft("sys", "user", SCHEMA))\n+ check(result == {"ok": 1} and "leak" not in result,\n+ "only final (non-thought) parts are parsed")\n+\n+ def fake_post_only_thought(url, json=None, timeout=None, headers=None):\n+ return gemini_ok([{"thought": True, "text": GOOD}])\n+\n+ try:\n+ with_post(fake_post_only_thought,\n+ lambda: provider.draft("sys", "user", SCHEMA))\n+ check(False, "thought-only response must raise")\n+ except providers.ProviderError:\n+ check(True, "thought-only response raises ProviderError")\n+ print("test_gemini_thought_parts_excluded: done")\n+\n+\n+def test_gemini_format_repair():\n+ calls = []\n+\n+ def fake_post(url, json=None, timeout=None, headers=None):\n+ calls.append(json)\n+ if len(calls) == 1:\n+ return gemini_ok([{"text": '{"ok": 1'}]) # truncated syntax\n+ return gemini_ok([{"text": GOOD}])\n+\n+ provider = providers.GeminiProvider("gemini-3.6-flash")\n+ result = with_post(fake_post,\n+ lambda: provider.draft("sys", "user", SCHEMA))\n+ check(result == {"ok": 1}, "gemini repair produced a parseable draft")\n+ check(len(calls) == 2, f"exactly one repair call, got {len(calls)}")\n+ repair_config = calls[1]["generationConfig"]\n+ check(repair_config.get("thinkingConfig") == {"thinkingLevel": "minimal"},\n+ "repair drops to minimal thinking")\n+ check(calls[1]["systemInstruction"]["parts"][0]["text"]\n+ == providers.REPAIR_SYSTEM_PROMPT,\n+ "repair uses the syntax-only system prompt")\n+ print("test_gemini_format_repair: done")\n+\n+\n+def main():\n+ tests = [\n+ test_nvidia_model_profiles,\n+ test_nvidia_reasoning_trace_never_parsed,\n+ test_nvidia_format_repair,\n+ test_gemini_model_profiles,\n+ test_gemini_thought_parts_excluded,\n+ test_gemini_format_repair,\n+ ]\n+ for test in tests:\n+ test()\n+ print(f"{_pass} checks passed, {_fail} failed")\n+ if _fail:\n+ sys.exit(1)\n+ print("OK")\n+\n+\n+if __name__ == "__main__":\n+ main()\n@@ -218,6 +218,8 @@ def fake_draft(client, group):\n check(biz.get("eventStatus") == "confirmed", "eventStatus stored")\n check(biz.get("entities", {}).get("organizations")\n == ["International Air Transport Association"], "entities stored")\n+ check(biz.get("sourcePublishedUtc") == "2026-07-24T09:00Z",\n+ f"source publish time recorded, got {biz.get('sourcePublishedUtc')}")\n \n # --- the safety story sits in the review queue, not on the site --------\n review = load(DATA / "review.json")\n@@ -309,6 +311,9 @@ def fake_draft(client, group):\n "investigation"],\n "risk flags carried onto the published article")\n check(len(safety.get("facts", [])) == 2, "verified facts carried over")\n+ check(safety.get("sourcePublishedUtc") == "2026-07-25T23:40Z",\n+ f"newest source publish time kept through approval, "\n+ f"got {safety.get('sourcePublishedUtc')}")\n check(load(DATA / "review.json") == [], "review queue emptied after approval")\n flashes = load(DATA / "flashes.json")\n check(flashes[0]["articleId"] == safety["id"] and flashes[0]["hot"] is True,\n@@ -483,6 +488,17 @@ def test_extract_json_and_validate_draft():\n broken["eventStatus"] = "finalized"\n check("eventStatus" in (write.validate_draft(broken) or ""),\n "unknown eventStatus rejected")\n+\n+ ihm = common.item_has_material\n+ check(ihm({"title": "T", "summary": "A real sentence with plenty of "\n+ "actual evidence text."}),\n+ "real summary counts as material")\n+ check(not ihm({"title": "FAA issues new rule on flight time limits",\n+ "summary": "FAA issues new rule on flight time limits"}),\n+ "summary that merely repeats the headline is NOT material")\n+ check(not ihm({"title": "T", "summary": ""}), "empty summary rejected")\n+ check(not ihm({"title": "T", "summary": "too short"}),\n+ "sub-threshold summary rejected")\n broken = json.loads(json.dumps(DRAFT_BIZ))\n broken["cat"] = "sports"\n check("cat" in (write.validate_draft(broken) or ""), "bad cat rejected")\n@@ -563,7 +579,7 @@ def test_model_chain_and_routing_policy():\n check(labels == ["nvidia:nvidia/nemotron-3-ultra-550b-a55b",\n "nvidia:nvidia/nemotron-3-super-120b-a12b",\n "gemini:gemini-3-flash-preview",\n- "nvidia:deepseek-ai/deepseek-v3.1"],\n+ f"nvidia:{providers.NVIDIA_DEFAULT_MODEL}"],\n f"order tokens honored in order, got {labels}")\n \n # --- platform auth death skips same-platform fallback models ----------