fix(curate): guard _salvage_objects against None LLM content

The LLM (Opus score stage / Haiku cards stage) can return None or empty
content, causing _salvage_objects to crash with
'NoneType' object has no attribute 'find' — which killed the curation
pipeline and returned zero matches to the user.

- Guard _salvage_objects and _salvage_kept against None/empty input
- Add explicit empty-content checks in both stages with logging
- Log content[:200] on unparseable score stage for QA diagnostics
This commit is contained in:
-Puter
2026-07-09 13:17:50 +05:30
parent db5c606783
commit 28cacfcc7c

View File

@@ -121,10 +121,12 @@ def _parse_json(text: str) -> dict:
return json.loads(t)
def _salvage_objects(text: str, *, after: str | None = None) -> list[dict]:
def _salvage_objects(text: str | None, *, after: str | None = None) -> list[dict]:
"""Recover valid objects from malformed/truncated model JSON: brace-scan each top-level {...} and
json.loads it on its own, skipping the one broken/cut-off object. `after` anchors the scan past a key
(e.g. '"kept"'); omit it to scan the whole text (the cards stage uses '"cards"', not '"kept"')."""
if not text:
return []
s = text
if after:
i = text.find(after)
@@ -145,9 +147,7 @@ def _salvage_objects(text: str, *, after: str | None = None) -> list[dict]:
pass
start = None
return out
def _salvage_kept(text: str) -> list[dict]:
def _salvage_kept(text: str | None) -> list[dict]:
"""Score-stage salvage — recover kept-job objects (anchored after the '"kept"' key)."""
return _salvage_objects(text, after='"kept"')
@@ -216,12 +216,15 @@ def _score_stage(prefs: dict, ctx: dict | None, sifted_jobs: list[dict], s) -> l
except Exception as e: # noqa: BLE001
logger.warning("score stage (Opus) failed, falling back to sift: %s", e)
return None
if not content or not content.strip():
logger.warning("score stage LLM returned empty content — falling back to sift")
return None
try:
kept = _parse_json(content).get("kept") or []
except Exception: # noqa: BLE001
kept = _salvage_kept(content)
if not kept:
logger.warning("score stage unparseable — falling back to sift")
logger.warning("score stage unparseable — falling back to sift (content[:200]=%r)", content[:200])
return None
logger.warning("score stage JSON malformed; salvaged %d items", len(kept))
# Floor check on the RAW computed score (weak never shown), THEN the transparent presentation
@@ -256,6 +259,9 @@ def _cards_stage(prefs: dict, ctx: dict | None, scored: list[dict], by_id: dict,
except Exception as e: # noqa: BLE001 — prose is non-critical; degrade to templated cards
logger.warning("cards stage (Haiku) call failed, using evidence-based fallback prose: %s", e)
return {}
if not content or not content.strip():
logger.warning("cards stage LLM returned empty content — using evidence-based fallback prose")
return {}
try:
cards = _parse_json(content).get("cards") or []
except Exception: # noqa: BLE001