Rifqi Hafizuddin
Claude Opus 5.5
[NOTICKET] feat(scope): code-owned conversation scope β€” focus from value lists, follow-up carry, scope line
111ef6b
Raw History Blame
5.55 kB
"""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)