"""chat/narrative_surface.py — Phase 3.3: NARRATIVE intent composer. When a prompt classifies as NARRATIVE — "Tell me about X", "Describe X", "What can you say about X" — the composer walks every reviewed chain rooted on X across every registered teaching corpus and emits a multi-clause surface that surfaces *everything* the system has reviewed about X. Sibling to: - :func:`chat.teaching_grounding.teaching_grounded_surface` — surfaces ONE chain rooted on X for a specific intent. - :func:`chat.teaching_grounding.teaching_grounded_surface_composed` — extends one chain with a follow-up (depth-1 chain-of-chains). - :func:`chat.pack_grounding.pack_grounded_surface` — surfaces X's pack semantic_domains. Whereas those composers pick one chain or one extension, NARRATIVE aggregates *every distinct (predicate, object) clause* rooted on X across both cause and verification intents. Surface format: "{X} — narrative-grounded ({corpus_ids}): {dX1}; {dX2}. {X} {conn1} {O1} ({dO1}); {X} {conn2} {O2} ({dO2}); ... No session evidence yet." Design constraints (matching ADR-0052..0065 doctrine): - **No content synthesis.** Every visible non-template token is either the lemma X, a verbatim pack ``semantic_domains`` atom, a reviewed chain object lemma, or a fixed connective from ``humanize_predicate``. - **Deterministic ordering.** Clauses sort by (intent_name, connective, object) so identical corpus state always produces the identical surface. - **Dedup by (connective, object).** When cause and verification carry the same predicate + object, only one clause is emitted — the dual-tag is implicit in the chain provenance and adding both reads as noise to the user. - **Pack-internal.** Chains are loaded from the cross-corpus aggregator (:func:`_all_chains_index`); each chain's object domains are read from its bound pack via :func:`_pack_for_corpus`. - **Bounded clause count.** Default ``max_clauses=4`` to keep the surface readable. Operators can raise the cap for analytic workloads. Returns ``None`` when no chain references X as subject — caller falls through to the pack-grounded surface (DEFINITION-like narrative) or to the OOV invitation if X is also not pack-resident. """ from __future__ import annotations from chat.cross_pack_grounding import cross_pack_chains_for_subject from chat.pack_resolver import _pack_lexicon_for, resolve_lemma from packs.register.loader import RegisterPack, UNREGISTERED from chat.teaching_grounding import ( _all_chains_index, _pack_for_corpus, ) from generate.semantic_templates import humanize_predicate def _object_domains_for_chain(chain) -> tuple[str, ...]: """Return the object lemma's semantic domains for *chain*. Handles both in-pack ``TeachingChain`` (residency via its bound corpus pack) and cross-pack ``CrossPackChain`` (residency in its declared ``object_pack_id``). """ object_pack_id = getattr(chain, "object_pack_id", None) if object_pack_id: return _pack_lexicon_for(object_pack_id).get(chain.object, ()) return _pack_for_corpus(chain.corpus_id).get(chain.object, ()) def _subject_domains_for_chain(chain) -> tuple[str, ...]: """Same as :func:`_object_domains_for_chain` but for the subject.""" subject_pack_id = getattr(chain, "subject_pack_id", None) if subject_pack_id: return _pack_lexicon_for(subject_pack_id).get(chain.subject, ()) return _pack_for_corpus(chain.corpus_id).get(chain.subject, ()) def narrative_grounded_surface( subject_lemma: str, *, max_clauses: int = 4, register: RegisterPack = UNREGISTERED, ) -> str | None: """Return a deterministic NARRATIVE-tier surface, or ``None``. Aggregates every reviewed chain whose subject equals *subject_lemma* across all registered teaching corpora. Dedups by (connective, object). Sorts clauses lexicographically for replay stability. ``max_clauses`` caps the emitted clause count. Default 4 reads smoothly; operators can raise for analytic workloads. Returns ``None`` when no chain references *subject_lemma* — the caller routes through pack-grounded DEFINITION (or OOV if the lemma is unknown). """ if not subject_lemma or not isinstance(subject_lemma, str): return None key = subject_lemma.strip().lower() if not key: return None if max_clauses < 1: return None index = _all_chains_index() matching: list = [ chain for (s, _), chain in index.items() if s == key ] # ADR-0067 — merge cross-pack chains rooted on the same subject. # In-pack chains take precedence on (intent, connective, object) # collision (first-occurrence-wins in dedup loop below). matching.extend(cross_pack_chains_for_subject(key)) if not matching: return None # Dedup by (connective, object) — verification and cause carrying # the same predicate produce one clause, not two. Stable sort # by (intent, connective, object) so replay produces byte-identical # output. seen: set[tuple[str, str]] = set() deduped: list = [] for chain in sorted( matching, key=lambda c: (c.intent, c.connective, c.object), ): sig = (chain.connective, chain.object) if sig in seen: continue seen.add(sig) deduped.append(chain) if len(deduped) >= max_clauses: break # Subject domains: take from the first chain's bound pack so the # narrative header is sourced from the lemma's own pack — even # when the matching chains span multiple corpora. first = deduped[0] subject_domains = _subject_domains_for_chain(first) if not subject_domains: # Fall back to cross-pack resolver — subject may live in a # different pack than its chains' corpus binding (defensive). resolved = resolve_lemma(first.subject) if resolved is None: return None subject_domains = resolved[1] head_subject = "; ".join( subject_domains[: max(1, first.domains_subject_k)] ) # Collect involved corpora for the tag. corpora = tuple(sorted({c.corpus_id for c in deduped})) corpora_tag = corpora[0] if len(corpora) == 1 else " + ".join(corpora) # Emit one clause per deduped chain. clauses: list[str] = [] for chain in deduped: obj_domains = _object_domains_for_chain(chain) if not obj_domains: continue obj_head = "; ".join( obj_domains[: max(1, chain.domains_object_k)] ) connective = humanize_predicate(chain.connective) clauses.append( f"{chain.subject} {connective} {chain.object} ({obj_head})" ) if not clauses: return None return ( f"{first.subject} — narrative-grounded ({corpora_tag}): " f"{head_subject}. {'; '.join(clauses)}. " f"No session evidence yet." ) __all__ = ["narrative_grounded_surface"]