11from __future__ import annotations
22
3+ import json
34import logging
45from collections import Counter
56from dataclasses import dataclass
@@ -180,6 +181,7 @@ def build_portfolio_truth_snapshot(
180181 catalog_path : Path | None = None ,
181182 legacy_registry_path : Path | None = None ,
182183 include_notion : bool = True ,
184+ notion_context_fallback : dict [str , dict [str , str ]] | None = None ,
183185 now : datetime | None = None ,
184186 release_count_by_name : dict [str , int ] | None = None ,
185187 security_alerts_by_name : dict [str , dict ] | None = None ,
@@ -188,6 +190,18 @@ def build_portfolio_truth_snapshot(
188190 catalog_data = load_portfolio_catalog (catalog_path )
189191 legacy_rows = load_legacy_registry_rows (legacy_registry_path )
190192 notion_context = load_safe_notion_project_context () if include_notion else {}
193+ notion_context_carried_forward = False
194+ if include_notion and not notion_context and notion_context_fallback :
195+ # Live Notion was unavailable; carry forward the prior published context so
196+ # a headless refresh updates risk/activity signals without dropping advisory
197+ # data to zero. The caller opts in via publish_portfolio_truth(allow_empty_notion=True).
198+ notion_context = notion_context_fallback
199+ notion_context_carried_forward = True
200+ logger .warning (
201+ "Live Notion context unavailable; carrying forward %d project rows "
202+ "from the prior portfolio-truth artifact." ,
203+ len (notion_context ),
204+ )
191205
192206 workspace_projects = discover_workspace_projects (
193207 workspace_root ,
@@ -217,6 +231,7 @@ def build_portfolio_truth_snapshot(
217231 "catalog_warnings" : list (catalog_data .get ("warnings" ) or []),
218232 "legacy_registry_rows" : len (legacy_rows ),
219233 "notion_context_rows" : len (notion_context ),
234+ "notion_context_carried_forward" : notion_context_carried_forward ,
220235 "context_quality_counts" : dict (
221236 Counter (project .derived .context_quality for project in projects )
222237 ),
@@ -251,6 +266,46 @@ def build_portfolio_truth_snapshot(
251266 )
252267
253268
269+ def load_prior_notion_context (latest_path : Path ) -> dict [str , dict [str , str ]]:
270+ """Reconstruct a Notion project-context map from a previously published
271+ portfolio-truth artifact, keyed identically to live Notion context
272+ (``_normalize(display_name)`` -> ``{portfolio_call, momentum, current_state}``).
273+
274+ Used to carry advisory context forward on a headless refresh when a live
275+ Notion token is unavailable, rather than overwriting local truth with zero
276+ rows. Only projects that actually carried Notion advisory are returned, so
277+ the resulting row count reflects real carried context. Returns an empty map
278+ when the artifact is missing or malformed.
279+ """
280+ try :
281+ data = json .loads (latest_path .read_text ())
282+ except (OSError , json .JSONDecodeError ):
283+ return {}
284+ projects = data .get ("projects" )
285+ if not isinstance (projects , list ):
286+ return {}
287+ context : dict [str , dict [str , str ]] = {}
288+ for project in projects :
289+ if not isinstance (project , dict ):
290+ continue
291+ identity = project .get ("identity" )
292+ advisory = project .get ("advisory" )
293+ if not isinstance (identity , dict ) or not isinstance (advisory , dict ):
294+ continue
295+ display_name = str (identity .get ("display_name" , "" )).strip ()
296+ portfolio_call = str (advisory .get ("notion_portfolio_call" , "" )).strip ()
297+ momentum = str (advisory .get ("notion_momentum" , "" )).strip ()
298+ current_state = str (advisory .get ("notion_current_state" , "" )).strip ()
299+ if not display_name or not (portfolio_call or momentum or current_state ):
300+ continue
301+ context [_normalize (display_name )] = {
302+ "portfolio_call" : portfolio_call ,
303+ "momentum" : momentum ,
304+ "current_state" : current_state ,
305+ }
306+ return context
307+
308+
254309def _duplicate_display_names (projects : list [PortfolioTruthProject ]) -> list [str ]:
255310 return sorted (
256311 name
0 commit comments