File size: 5,745 Bytes
e0411e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e5b0e3
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
"""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