Source code for scitex_clew._claim._hints

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Manuscript hints export — prose-level "things wrong with this manuscript" feed.

Companion export to :func:`scitex_clew._claim._manuscript.export_manuscript_claims`
but a DIFFERENT, SEPARATE concern with a different consumer (confirmed by
contract with scitex-writer, 2026-07-14; do not merge the two):

* ``export_manuscript_claims`` -> ``.scitex/clew/runtime/claims.json`` -> the
  compile-time claim/value/citation VERIFICATION feed (per-entry render
  markers: green/amber/red + link).
* ``export_manuscript_hints`` (this module) -> ``.scitex/writer/hints.json``
  -> scitex-writer's prose-level MANUSCRIPT-HINTS feed: citation problems,
  ungrounded claims, source-gate failures — an author-facing "what needs
  fixing" list, not a per-claim render marker.

Locked design (scitex-todo card ``clew-feat-manuscript-findings-producer``,
decided 2026-07-04). Do not deviate from field names/shapes without
renegotiating the contract with scitex-writer + scitex-scholar.

Schema ``"manuscript-hints/1"``::

    {
      "schema": "manuscript-hints/1",
      "generated_at": "2026-07-14T00:00:00Z",
      "hints": [
        {
          "id": "hint_<12-hex>",   # deterministic: sha256(claim_id:kind)[:12]
          "kind": "claim-mismatch",  # machine-readable reason tag
          "severity": "error",       # error | warning | advice
          "message": "...",          # prose, human-readable
          "location": {"file": ..., "line": ..., "page": null},
          "claim_id": "...",         # join key back to the claim/citation
          "source": "scitex-clew",
        },
        ...
      ]
    }

Topology: clew is the SINGLE producer of citation + claim + stat hints. It
reads (a) clew's own claim ledger (:func:`scitex_clew._claim.list_claims`,
full-8 resolved status via the same chain-flags / registered-source-gate
logic as :func:`export_manuscript_claims`) and (b) clew's own citation
ledger (:func:`scitex_clew.list_citations`, populated from scholar's
``citation_status``-schema artifact via the EXISTING io-observer ingest seam,
:mod:`scitex_clew._citation._ingest` / :mod:`scitex_clew._observers`) — it
does NOT re-read scholar's raw artifact directly; clew's citation ledger IS
the already-ingested view of it.

Severity mapping
-----------------
Claim domain (full-8 resolved status — the same taxonomy as
``scitex_clew._claim._model._CLAIM_PALETTE`` / ``_resolve_status``):

* ``mismatch`` / ``missing``     -> error   (broken provenance — hash drift
  or a vanished source file)
* ``suspect`` / ``registered``   -> warning (unconfirmed chain / not yet
  verified at all)
* ``unsourced`` / ``exception``  -> advice  (needs a look, not broken —
  ungrounded per the opt-in source gate, or an author-declared exception)
* ``verified`` / ``frozen``      -> silent  (omit — a clean, passing claim)

Citation domain (clew's INGESTED ``Citation.status`` — the 4-value
vocabulary in ``scitex_clew._citation.CITATION_STATUSES``). Note scholar's
richer future distinctions (DOI-mismatch / hallucinated / semantic_mismatch)
are not yet separate stored values on the ingested ledger — they collapse
onto ``stub`` / ``unverified`` today. Any status this module does not
recognize (e.g. a future ``semantic_mismatch``) falls back to ``advice`` —
forward-compatible without requiring that value to exist yet:

* ``verified``            -> silent (resolved, omit)
* ``stub`` / ``unverified`` -> warning
* ``unknown``              -> advice
* anything else (future)   -> advice (safe fallback)

``\\ref`` / citation-key rendering stays scitex-writer's responsibility —
each hint only carries ``claim_id`` (the join key back to the claim/
citation); this module never tries to resolve ``\\cite{...}`` text itself.
"""

from __future__ import annotations

import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Union

from ._export import _resolve_chain_flags
from ._model import _resolve_status
from ._register import list_claims

# Claim full-8 resolved-status -> hint severity. A status absent from this
# map (``verified`` / ``frozen``) is deliberately silent -> no hint emitted.
_CLAIM_SEVERITY: Dict[str, str] = {
    "mismatch": "error",
    "missing": "error",
    "suspect": "warning",
    "registered": "warning",
    "unsourced": "advice",
    "exception": "advice",
}

_CLAIM_MESSAGE: Dict[str, str] = {
    "mismatch": "Claim value does not match its recorded source (hash mismatch).",
    "missing": "Claim's source file is missing.",
    "suspect": "Claim's provenance chain is broken or unconfirmed.",
    "registered": "Claim has been registered but not yet verified.",
    "unsourced": "Claim does not trace to any registered source.",
    "exception": "Claim's provenance chain contains a declared exception.",
}

# Citation ledger status -> hint severity. Any status not listed here (a
# future scholar-side value) falls back to "advice" at the call site.
_CITATION_SEVERITY: Dict[str, Optional[str]] = {
    "verified": None,  # silent -- resolved, omit
    "stub": "warning",
    "unverified": "warning",
    "unknown": "advice",
}

_CITATION_MESSAGE: Dict[str, str] = {
    "stub": (
        "Citation is an unresolved stub/placeholder -- scitex-scholar has "
        "not confirmed a real source."
    ),
    "unverified": "Citation has not yet been confirmed by scitex-scholar.",
    "unknown": (
        "Citation key carries no bibliographic metadata (bare \\cite, no "
        "bib entry)."
    ),
}
_CITATION_MESSAGE_FALLBACK = (
    "Citation has an unrecognized scholar status; needs manual review."
)


def _generate_hint_id(claim_id: str, kind: str) -> str:
    """Deterministic hint id -- ``hint_<sha256(claim_id:kind)[:12]>``.

    Mirrors the claim-id precedent
    (:func:`scitex_clew._claim._model._generate_claim_id`): a stable,
    content-derived hash so re-running the export with unchanged inputs
    reproduces byte-identical ids (no randomness, no autoincrement).
    """
    key = f"{claim_id}:{kind}"
    h = hashlib.sha256(key.encode()).hexdigest()[:12]
    return f"hint_{h}"


def _claim_hint_entry(claim, grounded: Optional[bool] = None) -> Optional[Dict]:
    """Map one claim to a hint entry, or ``None`` if it needs no attention."""
    has_exception, has_frozen = _resolve_chain_flags(claim)
    resolved = _resolve_status(claim.status, has_exception, has_frozen, grounded)
    severity = _CLAIM_SEVERITY.get(resolved)
    if severity is None:
        return None
    kind = f"claim-{resolved}"
    return {
        "id": _generate_hint_id(claim.claim_id, kind),
        "kind": kind,
        "severity": severity,
        "message": _CLAIM_MESSAGE[resolved],
        "location": {
            "file": claim.file_path,
            "line": claim.line_number,
            "page": None,
        },
        "claim_id": claim.claim_id,
        "source": "scitex-clew",
    }


def _citation_hint_entry(citation) -> Optional[Dict]:
    """Map one citation node to a hint entry, or ``None`` if it needs no attention."""
    status = citation.status
    severity = _CITATION_SEVERITY.get(status, "advice")
    if severity is None:
        return None
    kind = f"citation-{status}"
    message = _CITATION_MESSAGE.get(status, _CITATION_MESSAGE_FALLBACK)
    return {
        "id": _generate_hint_id(citation.cite_key, kind),
        "kind": kind,
        "severity": severity,
        "message": message,
        "location": {
            "file": citation.manuscript_file,
            "line": citation.line_number,
            "page": None,
        },
        "claim_id": citation.cite_key,
        "source": "scitex-clew",
    }


[docs] def export_manuscript_hints( path: Optional[Union[str, Path]] = None, *, read_only: bool = True, ) -> Path: """Emit clew's prose-level manuscript hints to ``.scitex/writer/hints.json``. Reads clew's claim ledger + ingested citation ledger and emits ONE ``hints`` list in scitex-writer's ``manuscript-hints/1`` schema — the author-facing "what needs fixing" feed. This is a DIFFERENT, separate concern from :func:`export_manuscript_claims` (the per-claim render/ verification feed) — see the module docstring; do not confuse the two. MERGE-BY-SOURCE: if ``hints.json`` already exists with entries from other producers (e.g. figrecipe, or scitex-writer's own hints), this function replaces ONLY the entries where ``source == "scitex-clew"`` and leaves every other entry untouched (verbatim, original relative order). A missing or unparsable file is treated as empty and (re)created fresh with schema ``manuscript-hints/1``. Parameters ---------- path : str | Path, optional Output path. Resolution mirrors :func:`export_manuscript_claims`: explicit ``path`` > ``$SCITEX_CLEW_HINTS_JSON`` > ``<project_root>/.scitex/writer/hints.json`` (the canonical file scitex-writer reads). Pass an explicit path for a dedicated file. read_only : bool, optional ``chmod 0o444`` the file after writing (default True — it is derived). Returns ------- Path The path written (absolute). """ from .._db import _core as _db_core if path is None: env_path = os.environ.get("SCITEX_CLEW_HINTS_JSON") if env_path: path = Path(env_path) else: path = _db_core._default_hints_json_path(_db_core._find_project_root()) path = Path(path).resolve() path.parent.mkdir(parents=True, exist_ok=True) # Registered-source gate (opt-in): same manifest as export_manuscript_claims. from .._db import get_db from .._sources import is_grounded, load_sources_manifest _manifest = load_sources_manifest() _gate_active = _manifest is not None and _manifest.active _gate_db = get_db() def _grounded_for(claim): if not _gate_active: return None return is_grounded(claim, _manifest, _gate_db) clew_hints: List[Dict] = [] for c in list_claims(limit=100_000): entry = _claim_hint_entry(c, _grounded_for(c)) if entry is not None: clew_hints.append(entry) # Citation ledger optional (no citations ever ingested -> empty, not fatal). try: from .._citation import list_citations for cit in list_citations(limit=100_000): entry = _citation_hint_entry(cit) if entry is not None: clew_hints.append(entry) except Exception: # noqa: BLE001 — citation ledger optional pass # Deterministic ordering for clew's own slice (same inputs -> same bytes, # independent of sqlite row iteration order). clew_hints.sort(key=lambda h: h["id"]) # Merge-by-source: preserve every non-clew entry verbatim (and its # relative order); replace clew's slice wholesale. existing_payload: Dict = {} if path.exists(): try: loaded = json.loads(path.read_text()) if isinstance(loaded, dict): existing_payload = loaded except (OSError, json.JSONDecodeError, UnicodeDecodeError): existing_payload = {} foreign_hints = [ h for h in (existing_payload.get("hints") or []) if isinstance(h, dict) and h.get("source") != "scitex-clew" ] payload = dict(existing_payload) payload["schema"] = "manuscript-hints/1" payload["generated_at"] = datetime.now(timezone.utc).strftime( "%Y-%m-%dT%H:%M:%SZ" ) payload["hints"] = foreign_hints + clew_hints if path.exists(): try: path.chmod(0o644) except OSError: pass path.write_text(json.dumps(payload, indent=2, default=str)) if read_only: try: path.chmod(0o444) except OSError: pass return path
# EOF