"""Conversation scope — the slice of data an answer covers (2026-10-29). Scope used to exist only as text the planner might or might not apply: the objective's site/section default was applied on 7 of 27 turns that relied on it, and 4 of 42 answers named a scope their query never filtered (RCA 2026-10-29, docs in the session sprint folder). Here scope becomes state: - code resolves it: the objective and the user's own words matched against the value lists of the small categorical columns (`values.py`), the router's rewrite for follow-ups, the planner's grounded filters (`engine.py`); - the planner only declares "every value of this column" (`ScopeChange`); - code applies it to every `retrieve_data` IR before validation, renders the line the user sees, and persists a `TurnScope` on the turn's `AnalysisRecord`. Generic on purpose: an entry is any categorical catalog column by NAME plus its values. Nothing here knows what a "site" or "section" is. The period is not part of scope — the planner keeps resolving periods (a forecast or a year-over-year question needs data outside any fixed window); the line only shows the period that ran. """ from __future__ import annotations from typing import Literal from pydantic import BaseModel, Field class ScopeEntry(BaseModel): """One scoped column. `values=None` means ALL (the column is not filtered).""" column: str = Field( description="Catalog column NAME as shown in the catalog (e.g. a code column), " "never a column id." ) values: list[str] | None = Field( default=None, description="The values to keep, spelled as the catalog spells them. " "null = ALL values (the user asked for every value of this column).", ) class ScopeChange(BaseModel): """The planner's only scope output: a column the user asked to see ALL values of. Everything else is code (gate run 2, 2026-10-29): code matches the user's words against the column value lists and carries follow-ups from the router's rewrite. The planner asked for less is the planner that fails less — asked for a `base` and a full declaration, it invented `${scope.*}` placeholders. Concrete values are still read for columns too large to list (a model, a unit), if typed. """ set: list[ScopeEntry] = Field( default_factory=list, description="Only when the user asks for EVERY value of a column the analysis " "objective fixes ('semua section', 'per site', 'all sites'): that column with " "values null. Otherwise leave empty.", ) class ScopeInputs(BaseModel): """What a turn knows about scope before planning, read from the latest record. `focus=None` means not pinned yet (no earlier data answer resolved it): the planner is then asked to resolve the objective into `TaskList.focus`. `user_message` is the user's own words — the planner otherwise sees only the router's rewrite, which can carry history, and grounding a scope change needs what the user actually typed. """ focus: list[ScopeEntry] | None = None previous: list[ScopeEntry] | None = None user_message: str = "" # Full value lists of the small categorical columns (`values.load_value_index`), # loaded every data turn (cached per process): code resolves the objective and # the user's words with them. Never persisted. value_index: dict[str, list[str]] | None = None @classmethod def from_record_scope(cls, scope: dict | None, user_message: str) -> ScopeInputs: """Build from the latest persisted `TurnScope` dict; malformed → empty.""" try: prior = TurnScope.model_validate(scope) if scope else None except Exception: prior = None if prior is None: return cls(user_message=user_message) return cls( focus=(prior.focus or None) if prior.pinned else None, previous=prior.effective, user_message=user_message, ) class TurnScope(BaseModel): """The scope of one data answer, as persisted on its `AnalysisRecord`. `focus` is copied forward every turn so the latest record alone tells the next turn both the pinned focus and the previous answer's scope. """ focus: list[ScopeEntry] = Field(default_factory=list) effective: list[ScopeEntry] = Field(default_factory=list) base: Literal["focus", "previous"] = "focus" # Human-readable notes the line shows: entries that could not be applied to a # table, codes a lookup did not find, empty results. not_applied: list[str] = Field(default_factory=list) unknown_values: list[str] = Field(default_factory=list) line: str = "" # Review 2026-10-29 (adversarial pass on "code owns scope"): # - `pinned`: False when the focus came from an INCOMPLETE value index (a list # timed out); the next turn resolves it again instead of keeping half a focus. # - `partial`: columns some answering queries filtered and others did not (a # subset-vs-total structure, an unreachable table) — the line says so. # - `typed`: values the user typed this turn; only these can be "not found". pinned: bool = True partial: list[str] = Field(default_factory=list) typed: list[str] = Field(default_factory=list) # Focus columns a named unit set aside for this turn (the unit carries its own # site and section) — shown and reported as inside the focus, not a detour. lifted: list[str] = Field(default_factory=list) # 2026-11-03 (D1a): some query read a table with a `year` column — the line then # always says which period was used ("all periods" when none was filtered). periodic: bool = False