Rifqi Hafizuddin
Claude Opus 5.5
[NOTICKET] feat(scope): code-owned conversation scope — focus from value lists, follow-up carry, scope line
111ef6b Download src/agents/scope/engine.py from DataEyond/Agentic-Service-Data-Eyond-Catalog: direct link, hf CLI and curl.
- Browser
- Download file 51.9 kB
-
https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/refs%2Fpr%2F26/src/agents/scope/engine.py
- Command line
-
hf download hf://spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog@refs/pr/26/src/agents/scope/engine.py
-
curl -L -o engine.py https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/refs%2Fpr%2F26/src/agents/scope/engine.py
51.9 kB
| """Scope engine — merge, apply and render conversation scope in code (2026-10-29). | |
| The planner only DECLARES scope (`ScopeChange`, and once per analysis the | |
| objective's `focus`). Everything that decides what an answer actually covers lives | |
| here, because text the planner "should" apply was applied on 7 of 27 turns: | |
| - `resolve_scope` merges pinned focus / previous scope / this turn's change into | |
| the effective scope. A value is only accepted when the user actually typed it | |
| (`grounded`) — the planner may misread a message, it may not invent a code. | |
| - `apply_scope_to_plan` injects the effective scope into every `retrieve_data` | |
| IR, adding the lookup join when exactly one foreign key reaches the column. | |
| - `render_scope_line` describes what RAN (the executed filters in each output's | |
| meta), not what was intended, so the line can never name a scope its query | |
| never filtered. | |
| Pure and never-throw (house rule: these sit on the answer path). Generic: an | |
| entry is any non-PII string column, matched by NAME case-insensitively; nothing | |
| here knows what a site or a section is. Integer columns (year/month/week) are | |
| periods, not scope. | |
| `TaskList` / `RunState` / `ToolOutput` are read duck-typed and imported only for | |
| type checking: `planner.schemas` imports `scope.models`, so a runtime import back | |
| into the planner/slow-path packages would be circular. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from typing import TYPE_CHECKING, Any | |
| from src.catalog.models import Catalog, Column, Source, Table | |
| from src.middlewares.logging import get_logger | |
| from src.query.ir.models import FilterClause, Join, QueryIR | |
| from .models import ScopeChange, ScopeEntry, TurnScope | |
| if TYPE_CHECKING: | |
| from src.agents.planner.schemas import TaskList | |
| from src.agents.slow_path.schemas import RunState | |
| logger = get_logger("scope_engine") | |
| _EQ_OPS = ("=", "in") | |
| _MAX_OTHER_FILTERS = 4 | |
| # --------------------------------------------------------------------------- # | |
| # Small helpers | |
| # --------------------------------------------------------------------------- # | |
| def _norm(s: str) -> str: | |
| return re.sub(r"\s+", " ", s).strip().lower() | |
| def grounded(value: str, text: str) -> bool: | |
| """True when `value` appears in `text` as a whole token run. | |
| Case-insensitive and whitespace-collapsed ("COAL LOADER" matches "coal loader"). | |
| The value must be bounded by non-alphanumeric characters or the string ends, so | |
| "IPR" matches "kalo di ipr?" but not "iprx" — a code that merely prefixes another | |
| word is not something the user asked for. | |
| """ | |
| try: | |
| v = _norm(str(value or "")) | |
| if not v: | |
| return False | |
| t = _norm(str(text or "")) | |
| # [^\W_] = an alphanumeric char (unicode-aware), so the lookarounds forbid a | |
| # letter/digit on either side but allow punctuation, spaces and string ends. | |
| pattern = r"(?<![^\W_])" + re.escape(v) + r"(?![^\W_])" | |
| return re.search(pattern, t) is not None | |
| except Exception as e: | |
| logger.warning("scope_grounded_failed", extra={"error": repr(e)}) | |
| return False | |
| def _eligible(col: Column) -> bool: | |
| return col.data_type == "string" and not col.pii_flag | |
| def _eligible_columns(catalog: Catalog) -> dict[str, list[Column]]: | |
| """Eligible columns across the catalog, keyed by lower-cased NAME.""" | |
| out: dict[str, list[Column]] = {} | |
| for s in catalog.sources: | |
| for t in s.tables: | |
| for c in t.columns: | |
| if _eligible(c): | |
| out.setdefault(c.name.lower(), []).append(c) | |
| return out | |
| def _canonical_values(cols: list[Column]) -> dict[str, str]: | |
| """lower-cased value -> the catalog's spelling, from samples and top values.""" | |
| out: dict[str, str] = {} | |
| for c in cols: | |
| known: list[Any] = list(c.sample_values or []) | |
| if c.stats is not None and c.stats.top_values: | |
| known.extend(c.stats.top_values) | |
| for v in known: | |
| if v is None: | |
| continue | |
| out.setdefault(_norm(str(v)), str(v)) | |
| return out | |
| def _canon(value: str, canon: dict[str, str]) -> str: | |
| return canon.get(_norm(value), value) | |
| def _dedupe(items: list[str]) -> list[str]: | |
| seen: set[str] = set() | |
| out: list[str] = [] | |
| for i in items: | |
| if i not in seen: | |
| seen.add(i) | |
| out.append(i) | |
| return out | |
| def _upsert(entries: list[ScopeEntry], entry: ScopeEntry) -> None: | |
| """Replace the entry for the same column (case-insensitive), else append.""" | |
| for i, e in enumerate(entries): | |
| if e.column.lower() == entry.column.lower(): | |
| entries[i] = entry | |
| return | |
| entries.append(entry) | |
| def _filter_values(value: Any) -> list[str]: | |
| if isinstance(value, list | tuple): | |
| return [str(v) for v in value] | |
| return [str(value)] | |
| def _retrieve_calls(task_list: Any) -> list[Any]: | |
| calls: list[Any] = [] | |
| for task in getattr(task_list, "tasks", None) or []: | |
| for call in getattr(task, "tool_calls", None) or []: | |
| if getattr(call, "tool", None) == "retrieve_data": | |
| calls.append(call) | |
| return calls | |
| #: A value handoff from an earlier task: "${t1}" / "${t2.site_id}" (planner | |
| #: `PLACEHOLDER_RE` + the validator's task-id rule). Mirrored, not imported: the | |
| #: engine must not import planner modules at runtime (circular via planner.schemas). | |
| _HANDOFF_RE = re.compile(r"^\$\{t\d+(\.[A-Za-z0-9_]+)?\}$") | |
| def _is_handoff(value: Any) -> bool: | |
| return isinstance(value, str) and bool(_HANDOFF_RE.match(value.strip())) | |
| #: Name parts too generic to say WHICH column a user means ("site_code" → "site"). | |
| _GENERIC_NAME_PARTS = frozenset({"code", "id", "no", "name", "nm", "kd"}) | |
| def _column_named(column: str, text: str) -> bool: | |
| """True when the message names the column itself: its full name or a | |
| non-generic part of it ("section" for `section`, "site" for `site_code`).""" | |
| if grounded(column.replace("_", " "), text) or grounded(column, text): | |
| return True | |
| parts = [p for p in column.lower().split("_") if p and p not in _GENERIC_NAME_PARTS] | |
| return any( | |
| len(p) >= 3 and (grounded(p, text) or grounded(p + "s", text)) for p in parts | |
| ) | |
| def _quantified(column: str, text: str) -> bool: | |
| """True when a quantifier sits right before the column's word: "semua site", | |
| "per section", "all sites", "tiap section" — the user asked for every value of | |
| it, whatever the planner declared (test 3, 2026-10-30: "… di semua site" stayed on | |
| the focus site because the planner declared nothing).""" | |
| parts = {p for p in column.lower().split("_") if len(p) >= 3 and p not in _GENERIC_NAME_PARTS} | |
| words = re.findall(r"[a-z0-9]+", (text or "").lower()) | |
| for i, w in enumerate(words): | |
| if w in _QUANTIFIERS: | |
| for n in words[i + 1:i + 3]: | |
| if any(n == p or n == p + "s" for p in parts): | |
| return True | |
| return False | |
| def _cued(value: str, column: str, text: str) -> bool: | |
| """True when a non-generic part of `column` is one of the two words right before | |
| `value` in `text` ("di site SDJ" for `site_code`).""" | |
| parts = {p for p in column.lower().split("_") if len(p) >= 3 and p not in _GENERIC_NAME_PARTS} | |
| if not parts: | |
| return False | |
| words = re.findall(r"[a-z0-9]+", text.lower()) | |
| target = _norm(value) | |
| for i, w in enumerate(words): | |
| if w == target and any(p in words[max(0, i - 2):i] for p in parts): | |
| return True | |
| return False | |
| def _misplaced(value: str, column: str, value_index: dict[str, list[str]] | None) -> bool: | |
| """True when `value` is not in `column`'s list but IS in another column's list.""" | |
| if not value_index: | |
| return False | |
| lists = {k.lower(): {_norm(x) for x in v} for k, v in value_index.items()} | |
| own = lists.get(column.lower()) | |
| if own is None or _norm(value) in own: | |
| return False | |
| return any(_norm(value) in vals for k, vals in lists.items() if k != column.lower()) | |
| def _focus_from_index( | |
| value_index: dict[str, list[str]] | None, | |
| objective: str, | |
| eligible: dict[str, list[Column]], | |
| notes: list[str], | |
| require_cue: bool = False, | |
| ) -> list[ScopeEntry]: | |
| """Values of `objective` (any text) found in the column value lists, one entry | |
| per column. | |
| A value found under two DIFFERENT column names is ambiguous and dropped from | |
| both; never picks between them. With `require_cue` (the objective, which pins the | |
| focus for the whole analysis), a code of ≤3 letters counts only right after its | |
| column word ("site SDJ"): review 2026-10-29 pinned "HO" from "review ke HO". The | |
| user's follow-ups stay bare ("lat?", "kalo di bin?" — the real 7 Sep session). | |
| """ | |
| if not value_index or not objective: | |
| return [] | |
| hits: dict[str, list[str]] = {} | |
| owners: dict[str, set[str]] = {} | |
| for name, values in value_index.items(): | |
| cols = eligible.get(name.lower()) | |
| if not cols: | |
| continue | |
| for v in values: | |
| if not isinstance(v, str) or len(v.strip()) < 2: | |
| continue | |
| if require_cue and len(v.strip()) <= 3 and not _cued(v, cols[0].name, objective): | |
| continue | |
| if grounded(v, objective): | |
| hits.setdefault(cols[0].name, []).append(v) | |
| owners.setdefault(_norm(v), set()).add(cols[0].name.lower()) | |
| out: list[ScopeEntry] = [] | |
| for name, values in hits.items(): | |
| keep = [v for v in values if len(owners.get(_norm(v), set())) == 1] | |
| dropped = [v for v in values if v not in keep] | |
| # Longest match wins: "OB LOADER" is not also in scope when the objective | |
| # says "OB LOADER / DRILL PUMP". | |
| keep = [ | |
| v for v in keep | |
| if not any(o is not v and _norm(v) != _norm(o) and grounded(v, o) for o in keep) | |
| ] | |
| if dropped: | |
| notes.append(f"focus {name}: ambiguous across columns {dropped}") | |
| if keep: | |
| out.append(ScopeEntry(column=name, values=keep)) | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # 1. Merge | |
| # --------------------------------------------------------------------------- # | |
| #: Universal quantifiers — grammar, not domain vocabulary. A planner "every value of | |
| #: X" on a focus column is accepted when the user named the column OR used one of | |
| #: these ("semua jobsite", "per lokasi": review 2026-10-29, synonyms of `site`). | |
| _QUANTIFIERS = ("semua", "seluruh", "setiap", "tiap", "per", "all", "every", "each", "across") | |
| #: A column whose values identify ONE entity (unit numbers: 885-2,334 distinct on the | |
| #: production catalog; models 32-73). Naming one sets the other dimensions by the | |
| #: entity's own attributes (requirement #4) — the focus is not forced onto it. | |
| ENTITY_MIN_DISTINCT = 200 | |
| _NEG_OPS = ("!=", "not_in") | |
| _TASK_REF_RE = re.compile(r"\$\{(t\d+)") | |
| def _has_quantifier(text: str) -> bool: | |
| return any(grounded(q, text) for q in _QUANTIFIERS) | |
| def _entity_columns(catalog: Catalog) -> set[str]: | |
| out: set[str] = set() | |
| for s in catalog.sources: | |
| for t in s.tables: | |
| for c in t.columns: | |
| dc = c.stats.distinct_count if c.stats else None | |
| if _eligible(c) and dc is not None and dc > ENTITY_MIN_DISTINCT: | |
| out.add(c.name.lower()) | |
| return out | |
| class _PlanScan: | |
| """What the plan's retrieve_data IRs do with each eligible column (by name).""" | |
| def __init__(self, task_list: Any, catalog: Catalog) -> None: | |
| cols_by_id = { | |
| c.column_id: c for s in catalog.sources for t in s.tables for c in t.columns | |
| } | |
| # A group-by on a foreign-key id groups by its dimension too: `site_id` | |
| # (an int, never scope) → the site table's `site_code` (test 3, 2026-10-30: | |
| # "MTTR … di semua site" grouped by site_id and the site rule never fired). | |
| via_fk: dict[str, set[str]] = {} | |
| tables = {t.table_id: t for s in catalog.sources for t in s.tables} | |
| for t in tables.values(): | |
| for fk in t.foreign_keys: | |
| target = tables.get(fk.target_table_id) | |
| if target is not None: | |
| via_fk.setdefault(fk.column_id, set()).update( | |
| c.name.lower() for c in target.columns if _eligible(c) | |
| ) | |
| self.grouped: set[str] = set() | |
| self.filtered: set[str] = set() | |
| self.eq: dict[str, list[str]] = {} | |
| self.neg: dict[str, list[str]] = {} | |
| for call in _retrieve_calls(task_list): | |
| try: | |
| ir = QueryIR.model_validate((call.args or {}).get("ir")) | |
| except Exception as e: | |
| logger.info("scope_ir_unparseable", extra={"error": repr(e)}) | |
| continue | |
| for gid in ir.group_by: | |
| col = cols_by_id.get(gid) | |
| if col is not None and _eligible(col): | |
| self.grouped.add(col.name.lower()) | |
| self.grouped |= via_fk.get(gid, set()) | |
| for f in ir.filters: | |
| col = cols_by_id.get(f.column_id) | |
| if col is None or not _eligible(col): | |
| continue | |
| name = col.name.lower() | |
| self.filtered.add(name) | |
| bucket = ( | |
| self.eq if f.op in _EQ_OPS else self.neg if f.op in _NEG_OPS else None | |
| ) | |
| if bucket is None: | |
| continue | |
| values = bucket.setdefault(name, []) | |
| for v in _filter_values(f.value): | |
| if _norm(v) not in {_norm(x) for x in values}: | |
| values.append(v) | |
| def resolve_scope( | |
| *, | |
| pinned_focus: list[ScopeEntry] | None, | |
| previous: list[ScopeEntry] | None, | |
| change: ScopeChange | None, | |
| objective: str, | |
| raw_message: str, | |
| task_list: TaskList, | |
| catalog: Catalog, | |
| value_index: dict[str, list[str]] | None = None, | |
| rewritten: str = "", | |
| ) -> tuple[list[ScopeEntry], list[ScopeEntry], str, list[str]]: | |
| """Merge this turn's scope → `(focus, effective, base, log_notes)`. | |
| Code decides scope; the planner contributes only language it alone can judge | |
| (gate run 2, 2026-10-29: the more scope the planner was asked to handle, the more | |
| it failed). Order, every step deterministic: | |
| a. focus — pinned, else the objective matched against the column value lists (a | |
| code of ≤3 letters only right after its column word: "site SDJ", never "ke HO"); | |
| b. negation — a planner `!=`/`not_in` on values the user typed ("selain SDJ") | |
| opens that column (ALL); the planner's exclusion is kept as written; | |
| c. the user's own words against the FOCUS columns' lists (a value lands only on | |
| a column whose list holds it; "SDJ vs IPR" → site [SDJ, IPR]); | |
| d. the planner's `scope_change.set`: ALL when the user named the column or used a | |
| quantifier; values of columns too large to list, if typed; | |
| e. the router's carry of the previous answer's values into a follow-up — added to | |
| the user's value for a column the plan groups by ("bandingkan dengan SDJ"); | |
| f. the planner's filters on typed values (a code in no list is kept: "not found", | |
| never swapped for the focus; one that belongs to another column is not); | |
| g. a breakdown the user asked for ("per section") as ALL; | |
| h. an entity (a unit) named or carried lifts the focus columns for the turn. | |
| Never raises: a failure degrades to the pinned focus as the effective scope. | |
| """ | |
| try: | |
| notes: list[str] = [] | |
| eligible = _eligible_columns(catalog) | |
| indexed = {k.lower() for k, v in (value_index or {}).items() if v} | |
| scan = _PlanScan(task_list, catalog) | |
| def canon_col(name: str) -> str: | |
| cols = eligible.get(name.lower()) | |
| return cols[0].name if cols else name | |
| # a. focus | |
| if pinned_focus is not None: | |
| focus = [e.model_copy(deep=True) for e in pinned_focus] | |
| else: | |
| focus = _focus_from_index(value_index, objective, eligible, notes, require_cue=True) | |
| focus_cols = {e.column.lower() for e in focus} | |
| effective = [e.model_copy(deep=True) for e in focus] | |
| accepted: set[str] = set() # columns this message changed | |
| # b. negation | |
| negated: set[str] = set() | |
| for name, values in scan.neg.items(): | |
| if name in eligible and values and all(grounded(v, raw_message) for v in values): | |
| negated.add(name) | |
| notes.append(f"negated {name}: {values}") | |
| _upsert(effective, ScopeEntry(column=canon_col(name), values=None)) | |
| accepted.add(name) | |
| # c. the user's words against the focus columns' value lists | |
| focus_index = { | |
| k: v | |
| for k, v in (value_index or {}).items() | |
| if k.lower() in focus_cols and k.lower() not in negated | |
| } | |
| for entry in _focus_from_index(focus_index, raw_message, eligible, notes): | |
| notes.append(f"message {entry.column}: {entry.values}") | |
| _upsert(effective, entry) | |
| accepted.add(entry.column.lower()) | |
| # c2. "semua site" / "per section": every value of a focus column the user | |
| # did not give a value for — decided from the words, not the planner. | |
| for name in focus_cols - accepted: | |
| if _quantified(name, raw_message): | |
| notes.append(f"quantified {name}: ALL") | |
| _upsert(effective, ScopeEntry(column=canon_col(name), values=None)) | |
| accepted.add(name) | |
| # d. the planner's declaration | |
| for e in change.set if change is not None else []: | |
| name = e.column.lower() | |
| cols = eligible.get(name) | |
| if not cols or name in accepted: | |
| continue | |
| if e.values is None: | |
| # Widening a FOCUS column leaves the focus: the user must have named | |
| # it or said "every"; gate 1 saw section widened on "kalo IPR 2025?". | |
| if ( | |
| name in focus_cols | |
| and not _column_named(e.column, raw_message) | |
| and not _has_quantifier(raw_message) | |
| ): | |
| notes.append(f"set {e.column}=ALL: not asked for in the message") | |
| continue | |
| _upsert(effective, ScopeEntry(column=canon_col(e.column), values=None)) | |
| accepted.add(name) | |
| continue | |
| if name in focus_cols and name in indexed: | |
| continue # listed focus columns come from (c) only | |
| if not e.values or not all(grounded(v, raw_message) for v in e.values): | |
| notes.append(f"set {e.column}: values not in message {e.values}") | |
| continue | |
| if any(_misplaced(v, name, value_index) for v in e.values): | |
| notes.append(f"set {e.column}: values belong to another column {e.values}") | |
| continue | |
| canon = _canonical_values(cols) | |
| _upsert(effective, ScopeEntry(column=canon_col(e.column), | |
| values=[_canon(v, canon) for v in e.values])) | |
| accepted.add(name) | |
| # e. the router's carry. Only the router sees the chat history; it restates | |
| # the previous scope in a short follow-up ("terus komponennya?" → "… di site | |
| # IPR …") and, on the real sessions, never restated a switched site in the | |
| # complete question after it — so go-home holds unless the rewrite carries. | |
| base = "focus" | |
| carried: set[str] = set() | |
| for e in previous or []: | |
| name = e.column.lower() | |
| if not e.values or name in negated: | |
| continue | |
| if not all(grounded(v, rewritten) for v in e.values): | |
| continue | |
| current = next((x for x in effective if x.column.lower() == name), None) | |
| if name in accepted: | |
| # "bandingkan dengan SDJ" after IPR: a plan grouped by the column | |
| # compares the user's value WITH the previous one (review: the carry | |
| # was blocked and IPR vanished from the comparison). | |
| if name in scan.grouped and current is not None and current.values: | |
| merged = list(current.values) | |
| merged += [v for v in e.values if _norm(v) not in {_norm(m) for m in merged}] | |
| if len(merged) > len(current.values): | |
| notes.append(f"compared {e.column}: {merged}") | |
| _upsert(effective, ScopeEntry(column=e.column, values=merged)) | |
| carried.add(name) | |
| base = "previous" | |
| continue | |
| if current is None or current.values != e.values: | |
| notes.append(f"carried {e.column}: {e.values} (restated by the rewrite)") | |
| _upsert(effective, e.model_copy(deep=True)) | |
| carried.add(name) | |
| base = "previous" | |
| # f. the planner's filters on typed values | |
| adopted: set[str] = set() | |
| for name, values in scan.eq.items(): | |
| if name in accepted or name not in eligible: | |
| continue | |
| if not values or not all(grounded(v, raw_message) for v in values): | |
| continue | |
| # A typed value on a column whose list does not hold it but ANOTHER list | |
| # does is misplaced (gate 2: IPR on `section`) — not adopted. A value in | |
| # no list stays: an unknown code ("KMB") must be answered as "not found". | |
| if name in indexed and any(_misplaced(v, name, value_index) for v in values): | |
| continue | |
| canon = _canonical_values(eligible.get(name, [])) | |
| entry = ScopeEntry(column=canon_col(name), values=[_canon(v, canon) for v in values]) | |
| notes.append(f"adopted {entry.column}: {entry.values}") | |
| _upsert(effective, entry) | |
| adopted.add(name) | |
| # g. a breakdown the user asked for ("per section") is ALL for that column — | |
| # only when the user named the column (plans also group by descriptive | |
| # columns) and the plan does not filter it (a comparison grouped by sides). | |
| # A filter whose values neither the user nor the rewrite gave (the planner | |
| # copying the objective's SDJ onto a per-site question) does not make a | |
| # breakdown a comparison. | |
| said = f"{raw_message}\n{rewritten}" | |
| intended = { | |
| n for n, vals in scan.eq.items() if vals and all(grounded(v, said) for v in vals) | |
| } | set(scan.neg) | |
| for name in scan.grouped - intended - accepted: | |
| if not _column_named(name, raw_message): | |
| continue | |
| if any(e.column.lower() == name and e.values is not None for e in effective): | |
| notes.append(f"grouped {name}: ALL") | |
| _upsert(effective, ScopeEntry(column=canon_col(name), values=None)) | |
| # h. an entity (one unit) sets the other dimensions by its own attributes: a | |
| # unit outside the focus answered "not found" three times over (review). | |
| entities = _entity_columns(catalog) - focus_cols | |
| if any( | |
| e.column.lower() in entities and e.values | |
| and e.column.lower() in (accepted | carried | adopted) | |
| for e in effective | |
| ): | |
| lifted = [ | |
| e.column for e in effective | |
| if e.column.lower() in focus_cols and e.column.lower() not in accepted | carried | |
| ] | |
| if lifted: | |
| notes.append(f"entity named: focus lifted for {lifted}") | |
| effective = [e for e in effective if e.column not in lifted] | |
| return focus, effective, base, notes | |
| except Exception as e: | |
| logger.warning( | |
| "scope_resolve_failed", | |
| extra={"error": repr(e), "degraded_seam": "scope_resolve"}, | |
| ) | |
| try: | |
| fallback = [x.model_copy(deep=True) for x in pinned_focus or []] | |
| except Exception: | |
| fallback = [] | |
| return fallback, [x.model_copy(deep=True) for x in fallback], "focus", [ | |
| f"resolve_scope failed: {e!r}" | |
| ] | |
| # --------------------------------------------------------------------------- # | |
| # 2. Apply | |
| # --------------------------------------------------------------------------- # | |
| def _find_source_table(catalog: Catalog, ir: QueryIR) -> tuple[Source, Table] | None: | |
| for s in catalog.sources: | |
| if s.source_id != ir.source_id: | |
| continue | |
| for t in s.tables: | |
| if t.table_id == ir.table_id: | |
| return s, t | |
| return None | |
| def _lookup_path( | |
| name: str, in_play: list[Table], tables_by_id: dict[str, Table] | |
| ) -> list[tuple[Table, Any, Column]]: | |
| """(target table, fk, column) for each FK from a table in play to a table holding | |
| an eligible column called `name`.""" | |
| out: list[tuple[Table, Any, Column]] = [] | |
| for t in in_play: | |
| for fk in t.foreign_keys: | |
| target = tables_by_id.get(fk.target_table_id) | |
| if target is None: | |
| continue | |
| col = next( | |
| (c for c in target.columns if c.name.lower() == name and _eligible(c)), None | |
| ) | |
| if col is not None: | |
| out.append((target, fk, col)) | |
| return out | |
| def _strip_broken_id_filters( | |
| ir: QueryIR, owner: Table, in_play: list[Table], column: str, table_name: str | |
| ) -> bool: | |
| """Drop the planner's own handling of `owner`'s dimension through an id column — | |
| a typed id, a string on the int key, a made-up `${scope.site_id}` (gate run 2: 6 of | |
| 31 turns refused on these). A real handoff from a lookup task (`${t1.site_id}`) | |
| stays. True when something was dropped.""" | |
| id_cols = { | |
| fk.column_id | |
| for t in in_play | |
| if t is not owner | |
| for fk in t.foreign_keys | |
| if fk.target_table_id == owner.table_id | |
| } | |
| kept = [f for f in ir.filters if f.column_id not in id_cols or _is_handoff(f.value)] | |
| if len(kept) == len(ir.filters): | |
| return False | |
| logger.info("scope_id_filter_replaced", extra={"column": column, "table": table_name}) | |
| ir.filters = kept | |
| return True | |
| def _apply_to_ir( | |
| ir: QueryIR, | |
| source: Source, | |
| base_table: Table, | |
| effective: list[ScopeEntry], | |
| not_applied: list[str], | |
| respected: set[str] | None = None, | |
| grounded_text: str = "", | |
| sources_of: dict[str, set[str]] | None = None, | |
| ) -> bool: | |
| """Apply every concrete entry to one IR in place; True when it changed. | |
| Code fills gaps, corrects what is invalid or ungrounded, and respects what is | |
| grounded (2026-10-29, after gate run 2): | |
| - a filter on the column whose values are in the scope or in the user's / the | |
| rewrite's words is the planner's deliberate choice — kept; | |
| - any other filter on it (an invented value) is replaced by the scope; | |
| - no filter: the scope is added — unless `respected` says another query on the | |
| same table filters this column, i.e. the planner built a subset-vs-total | |
| structure ("IPR's share of all sites", "COAL LOADER vs every section"), where | |
| adding it would compare the subject with itself; | |
| - a column the query cannot reach (no column, no single FK) is noted, and so is a | |
| same-named column in a source whose value list was never read (a file's own | |
| `section` is not scoped by the database's values — review 2026-10-29). | |
| """ | |
| tables_by_id = {t.table_id: t for t in source.tables} | |
| in_play: list[Table] = [base_table] | |
| for j in ir.joins: | |
| t = tables_by_id.get(j.target_table_id) | |
| if t is not None and t not in in_play: | |
| in_play.append(t) | |
| changed = False | |
| for entry in effective: | |
| if entry.values is None: | |
| continue | |
| name = entry.column.lower() | |
| values = list(entry.values) | |
| known_in = (sources_of or {}).get(name) | |
| if known_in and source.source_id not in known_in: | |
| not_applied.append(f"{entry.column}: {base_table.name}") | |
| continue | |
| target_col = next( | |
| (c for t in in_play for c in t.columns if c.name.lower() == name and _eligible(c)), | |
| None, | |
| ) | |
| candidates = [] if target_col is not None else _lookup_path(name, in_play, tables_by_id) | |
| if target_col is not None: | |
| owner = next((t for t in in_play if target_col in t.columns), None) | |
| else: | |
| owner = candidates[0][0] if len(candidates) == 1 else None | |
| if owner is not None and _strip_broken_id_filters( | |
| ir, owner, in_play, entry.column, base_table.name | |
| ): | |
| changed = True | |
| existing = ( | |
| [f for f in ir.filters if f.column_id == target_col.column_id] | |
| if target_col is not None | |
| else [] | |
| ) | |
| if existing: | |
| wanted = {_norm(v) for v in values} | |
| if all( | |
| f.op in _EQ_OPS + _NEG_OPS | |
| and all( | |
| _norm(v) in wanted or grounded(v, grounded_text) | |
| for v in _filter_values(f.value) | |
| ) | |
| for f in existing | |
| ): | |
| # The planner's grounded choice: a comparison side, a switch, or an | |
| # exclusion the user asked for ("selain SDJ" — review: replacing a | |
| # grounded `not_in [SDJ]` with `in [SDJ]` inverted the question). | |
| continue | |
| logger.info( | |
| "scope_filter_conflict_replaced", | |
| extra={ | |
| "column": entry.column, | |
| "table": base_table.name, | |
| "replaced": [f.model_dump() for f in existing], | |
| "values": values, | |
| }, | |
| ) | |
| ir.filters = [f for f in ir.filters if f.column_id != target_col.column_id] | |
| elif respected and name in respected: | |
| continue # subset-vs-total: another query on this table carries the filter | |
| if target_col is None: | |
| # The column lives in a lookup table: reach it through the ONE foreign key | |
| # that leads there, or report it — never pick between two paths. | |
| if len(candidates) != 1 or source.source_type != "schema": | |
| not_applied.append(f"{entry.column}: {base_table.name}") | |
| continue | |
| target, fk, target_col = candidates[0] | |
| join = Join( | |
| target_table_id=fk.target_table_id, | |
| left_column_id=fk.column_id, | |
| right_column_id=fk.target_column_id, | |
| type="inner", | |
| ) | |
| if join not in ir.joins: | |
| ir.joins.append(join) | |
| if target not in in_play: | |
| in_play.append(target) | |
| # No group-by exemption: plans group by descriptive columns too (unit + model | |
| # + section on a ranking); a breakdown the user asked for is ALL by now. | |
| ir.filters.append( | |
| FilterClause( | |
| column_id=target_col.column_id, op="in", value=values, value_type="string" | |
| ) | |
| ) | |
| changed = True | |
| return changed | |
| def _drop_misplaced_filters( | |
| ir: QueryIR, catalog: Catalog, value_index: dict[str, list[str]] | None | |
| ) -> bool: | |
| """Remove a filter whose values belong to ANOTHER listed column (gate run 2: the | |
| planner put the site code IPR on `section`). The right column gets the value from | |
| the user's words. True when something was removed.""" | |
| if not value_index: | |
| return False | |
| cols_by_id = {c.column_id: c for s in catalog.sources for t in s.tables for c in t.columns} | |
| kept = [] | |
| for f in ir.filters: | |
| col = cols_by_id.get(f.column_id) | |
| if ( | |
| col is not None | |
| and _eligible(col) | |
| and f.op in _EQ_OPS | |
| and any(_misplaced(v, col.name, value_index) for v in _filter_values(f.value)) | |
| ): | |
| logger.info("scope_misplaced_filter_dropped", extra={"column": col.name}) | |
| continue | |
| kept.append(f) | |
| if len(kept) == len(ir.filters): | |
| return False | |
| ir.filters = kept | |
| return True | |
| def _filtered_columns(ir: QueryIR, catalog: Catalog) -> set[str]: | |
| cols_by_id = {c.column_id: c for s in catalog.sources for t in s.tables for c in t.columns} | |
| return { | |
| cols_by_id[f.column_id].name.lower() | |
| for f in ir.filters | |
| if f.column_id in cols_by_id and _eligible(cols_by_id[f.column_id]) and f.op in _EQ_OPS | |
| } | |
| def _open_in_ir( | |
| ir: QueryIR, source: Source, base_table: Table, opened: list[ScopeEntry], | |
| grounded_text: str, | |
| ) -> bool: | |
| """For a column the scope opened (ALL), drop the planner's `=`/`in` filters on | |
| values neither the user nor the rewrite gave — the objective's SDJ copied onto | |
| "MTTR … di semua site" (test 3, 2026-10-30) — and broken id filters on its | |
| lookup path. Grounded filters and exclusions stay. True when something changed.""" | |
| tables_by_id = {t.table_id: t for t in source.tables} | |
| in_play: list[Table] = [base_table] | |
| for j in ir.joins: | |
| t = tables_by_id.get(j.target_table_id) | |
| if t is not None and t not in in_play: | |
| in_play.append(t) | |
| changed = False | |
| for entry in opened: | |
| name = entry.column.lower() | |
| ids = {c.column_id for t in in_play for c in t.columns | |
| if c.name.lower() == name and _eligible(c)} | |
| kept = [ | |
| f for f in ir.filters | |
| if f.column_id not in ids | |
| or f.op not in _EQ_OPS | |
| or all(grounded(v, grounded_text) for v in _filter_values(f.value)) | |
| ] | |
| if len(kept) != len(ir.filters): | |
| logger.info("scope_ungrounded_filter_opened", extra={"column": entry.column}) | |
| ir.filters = kept | |
| changed = True | |
| candidates = _lookup_path(name, in_play, tables_by_id) | |
| if len(candidates) == 1 and _strip_broken_id_filters( | |
| ir, candidates[0][0], in_play, entry.column, base_table.name | |
| ): | |
| changed = True | |
| return changed | |
| def _widen_comparisons( | |
| parsed: list[Any], answering: list[int], concrete: list[ScopeEntry], | |
| catalog: Catalog, grounded_text: str, | |
| ) -> set[int]: | |
| """The user typed several values of a column ("SDJ dibanding IPR") but the | |
| answering queries together cover fewer — the planner fetched one side only (test | |
| 3: an A/B step treating IPR as a section over SDJ-only data; run 2: a lookup of | |
| SDJ alone). Every answering filter on that column is widened to all the values, | |
| so the data holds both sides. Returns the indexes of the IRs changed.""" | |
| cols_by_id = {c.column_id: c for s in catalog.sources for t in s.tables for c in t.columns} | |
| changed: set[int] = set() | |
| for entry in concrete: | |
| if len(entry.values or []) < 2 or not all( | |
| grounded(v, grounded_text) for v in entry.values | |
| ): | |
| continue | |
| name = entry.column.lower() | |
| want = {_norm(v) for v in entry.values} | |
| covered: set[str] = set() | |
| for i in answering: | |
| for f in parsed[i][2].filters: | |
| col = cols_by_id.get(f.column_id) | |
| if col is not None and col.name.lower() == name and f.op in _EQ_OPS: | |
| covered |= {_norm(v) for v in _filter_values(f.value)} | |
| if not covered or covered >= want: | |
| continue | |
| for i in answering: | |
| ir = parsed[i][2] | |
| hit = False | |
| for f in ir.filters: | |
| col = cols_by_id.get(f.column_id) | |
| if col is not None and col.name.lower() == name and f.op in _EQ_OPS: | |
| f.op, f.value = "in", list(entry.values) | |
| hit = True | |
| if hit: | |
| logger.info("scope_comparison_widened", extra={"column": entry.column}) | |
| changed.add(i) | |
| return changed | |
| def _lookup_task_ids(task_list: Any) -> set[str]: | |
| """Tasks whose output another task reads through a `${tN…}` handoff — lookups | |
| (a site code → its id), not queries that answer the question.""" | |
| out: set[str] = set() | |
| for task in getattr(task_list, "tasks", None) or []: | |
| for call in getattr(task, "tool_calls", None) or []: | |
| out.update(_TASK_REF_RE.findall(repr(getattr(call, "args", None)))) | |
| return out | |
| def apply_scope_to_plan( | |
| task_list: TaskList, | |
| effective: list[ScopeEntry], | |
| catalog: Catalog, | |
| *, | |
| value_index: dict[str, list[str]] | None = None, | |
| grounded_text: str = "", | |
| partial: list[str] | None = None, | |
| ) -> list[str]: | |
| """Apply the effective scope to every `retrieve_data` IR, in place. | |
| Three passes: drop filters whose values belong to another listed column; note | |
| which columns each table's queries filter (a subset-vs-total structure); then | |
| apply each entry per `_apply_to_ir`. `grounded_text` is the user's words plus | |
| the router's rewrite — a planner filter on a value found there is kept. | |
| Coverage is judged over the queries that ANSWER the question (a lookup feeding | |
| a handoff does not): an entry none of them filters is returned as a "not | |
| applied" note (`"<column>: <table>"`); one only some of them filter is appended | |
| to `partial` — the line says so instead of claiming the whole answer (review | |
| 2026-10-29: "Cakupan: … site SDJ" over a second number covering every site). | |
| An IR that does not parse, or that fails mid-way, is left exactly as the planner | |
| wrote it. Never raises. | |
| """ | |
| notes: list[str] = [] | |
| try: | |
| concrete = [e for e in effective or [] if e.values is not None] | |
| opened = [e for e in effective or [] if e.values is None] | |
| lookups = _lookup_task_ids(task_list) | |
| sources_of = getattr(value_index, "sources", None) | |
| parsed: list[tuple[str, Any, QueryIR, Source, Table, bool]] = [] | |
| for task in getattr(task_list, "tasks", None) or []: | |
| for call in getattr(task, "tool_calls", None) or []: | |
| if getattr(call, "tool", None) != "retrieve_data": | |
| continue | |
| args = getattr(call, "args", None) | |
| if not isinstance(args, dict): | |
| continue | |
| try: | |
| ir = QueryIR.model_validate(args.get("ir")) | |
| except Exception as e: | |
| logger.info("scope_ir_unparseable", extra={"error": repr(e)}) | |
| continue | |
| found = _find_source_table(catalog, ir) | |
| if found is None: | |
| continue # the validator reports an unknown table | |
| changed = _drop_misplaced_filters(ir, catalog, value_index) | |
| parsed.append((str(task.id), args, ir, found[0], found[1], changed)) | |
| if not parsed: | |
| return [] # nothing to apply to, so nothing to report as missed | |
| filtered = [_filtered_columns(ir, catalog) for _, _, ir, _, _, _ in parsed] | |
| local: list[list[str]] = [] | |
| for i, (_tid, args, ir, source, base_table, changed) in enumerate(parsed): | |
| local_notes: list[str] = [] | |
| try: | |
| respected: set[str] = set() | |
| for j, other in enumerate(parsed): | |
| if j != i and other[4].table_id == base_table.table_id: | |
| respected |= filtered[j] | |
| if concrete and _apply_to_ir( | |
| ir, source, base_table, concrete, local_notes, | |
| respected=respected - filtered[i], grounded_text=grounded_text, | |
| sources_of=sources_of, | |
| ): | |
| changed = True | |
| if opened and _open_in_ir(ir, source, base_table, opened, grounded_text): | |
| changed = True | |
| if changed: | |
| args["ir"] = ir.model_dump() | |
| except Exception as e: | |
| logger.warning( | |
| "scope_apply_ir_failed", | |
| extra={ | |
| "error": repr(e), "table": base_table.name, | |
| "degraded_seam": "scope_apply", | |
| }, | |
| ) | |
| local_notes = [f"{x.column}: {base_table.name}" for x in concrete] | |
| local.append(local_notes) | |
| answering = [i for i, p in enumerate(parsed) if p[0] not in lookups] or list( | |
| range(len(parsed)) | |
| ) | |
| for i in _widen_comparisons(parsed, answering, concrete, catalog, grounded_text): | |
| parsed[i][1]["ir"] = parsed[i][2].model_dump() | |
| after = [_filtered_columns(p[2], catalog) | _negated_columns(p[2], catalog) for p in parsed] | |
| for entry in concrete: | |
| name = entry.column.lower() | |
| took = [i for i in answering if name in after[i]] | |
| if not took: | |
| note = next( | |
| (n for i in answering for n in local[i] if n.lower().startswith(name + ":")), | |
| entry.column, | |
| ) | |
| notes.append(note) | |
| elif len(took) < len(answering) and partial is not None: | |
| partial.append(entry.column) | |
| return _dedupe(notes) | |
| except Exception as e: | |
| logger.warning( | |
| "scope_apply_failed", extra={"error": repr(e), "degraded_seam": "scope_apply"} | |
| ) | |
| return _dedupe(notes) | |
| def _negated_columns(ir: QueryIR, catalog: Catalog) -> set[str]: | |
| cols_by_id = {c.column_id: c for s in catalog.sources for t in s.tables for c in t.columns} | |
| return { | |
| cols_by_id[f.column_id].name.lower() | |
| for f in ir.filters | |
| if f.column_id in cols_by_id and _eligible(cols_by_id[f.column_id]) and f.op in _NEG_OPS | |
| } | |
| # --------------------------------------------------------------------------- # | |
| # 3. Render | |
| # --------------------------------------------------------------------------- # | |
| def _entry_map(entries: list[ScopeEntry]) -> dict[str, frozenset[str] | None]: | |
| return { | |
| e.column.lower(): (None if e.values is None else frozenset(_norm(v) for v in e.values)) | |
| for e in entries | |
| } | |
| def focus_changed( | |
| focus: list[ScopeEntry], | |
| effective: list[ScopeEntry], | |
| lifted: set[str] | frozenset[str] = frozenset(), | |
| ) -> bool: | |
| """True when the effective scope covers a different slice than the focus. | |
| Compared per FOCUS column as value sets (case-insensitive): a focus column | |
| missing from `effective`, widened to ALL, or set to other values is different. | |
| A column the focus does not name is NOT: one unit or model inside the focus is | |
| a narrower look at the focus, not a detour away from it (lead, 2026-10-29), so | |
| neither the chat line nor the report labels a drill-down as outside the focus. | |
| `lifted`: focus columns a named unit set aside for the turn (it carries its own | |
| site and section, usually inside the focus) — not counted as leaving it. | |
| Never raises (False on garbage). | |
| """ | |
| try: | |
| f = _entry_map(focus or []) | |
| e = _entry_map(effective or []) | |
| skip = {c.lower() for c in lifted} | |
| return any( | |
| col not in e or e[col] != vals for col, vals in f.items() if col not in skip | |
| ) | |
| except Exception as ex: | |
| logger.warning("scope_focus_changed_failed", extra={"error": repr(ex)}) | |
| return False | |
| def _render_entry(entry: ScopeEntry, id_: bool) -> str: | |
| if entry.values is None: | |
| return f"{entry.column}: {'semua' if id_ else 'all'}" | |
| return f"{entry.column} {', '.join(entry.values)}" | |
| def _render_filter(f: dict[str, Any]) -> str: | |
| col, op, value = f.get("column"), f.get("op"), f.get("value") | |
| if op == "=": | |
| return f"{col} {value}" | |
| if op == "in" and isinstance(value, list | tuple): | |
| return f"{col} {', '.join(str(v) for v in value)}" | |
| if op == "between" and isinstance(value, list | tuple) and len(value) == 2: | |
| return f"{col} {value[0]}–{value[1]}" | |
| if value is None: | |
| return f"{col} {op}" | |
| return f"{col} {op} {value}" | |
| def _as_int(v: Any) -> int | None: | |
| if v is None or isinstance(v, bool): | |
| return None | |
| try: | |
| if isinstance(v, float): | |
| return int(v) if v.is_integer() else None | |
| return int(str(v).strip()) | |
| except (TypeError, ValueError): | |
| return None | |
| def _meta_filters(out: Any) -> list[dict[str, Any]]: | |
| meta = getattr(out, "meta", None) or {} | |
| filters = meta.get("filters") if isinstance(meta, dict) else None | |
| return [f for f in filters or [] if isinstance(f, dict)] | |
| def _coverage(outputs: list[Any]) -> str | None: | |
| periods: list[tuple[int, int]] = [] | |
| for out in outputs: | |
| cols = [str(c).lower() for c in getattr(out, "columns", None) or []] | |
| if "year" not in cols or "month" not in cols: | |
| continue | |
| yi, mi = cols.index("year"), cols.index("month") | |
| for row in getattr(out, "rows", None) or []: | |
| if not isinstance(row, list | tuple) or len(row) <= max(yi, mi): | |
| continue | |
| y, m = _as_int(row[yi]), _as_int(row[mi]) | |
| if y is not None and m is not None: | |
| periods.append((y, m)) | |
| if not periods: | |
| return None | |
| lo, hi = min(periods), max(periods) | |
| if lo == hi: | |
| return f"data {lo[0]:04d}-{lo[1]:02d}" | |
| return f"data {lo[0]:04d}-{lo[1]:02d}–{hi[0]:04d}-{hi[1]:02d}" | |
| def render_scope_line( | |
| scope: TurnScope, run_state: RunState, reply_language: str | None | |
| ) -> TurnScope: | |
| """Fill `line`, `not_applied` and `unknown_values` from what actually RAN. | |
| Every part of the line is read from the executed `retrieve_data` outputs' | |
| `meta.filters`, so an entry the queries never filtered is marked "not applied" | |
| instead of being claimed. A lookup filtered only on scope values that returned | |
| no rows means the user named a code that does not exist. Indonesian when the | |
| reply language says so, else English. Never raises: on failure the scope comes | |
| back unchanged. | |
| """ | |
| try: | |
| id_ = "indonesia" in (reply_language or "").lower() | |
| outputs: list[Any] = [] | |
| for result in (getattr(run_state, "results", None) or {}).values(): | |
| for out in getattr(result, "outputs", None) or []: | |
| if getattr(out, "tool", None) == "retrieve_data" and getattr( | |
| out, "kind", None | |
| ) == "table": | |
| outputs.append(out) | |
| effective = list(scope.effective) | |
| eff_cols = {e.column.lower() for e in effective} | |
| filtered_cols = { | |
| str(f.get("column", "")).lower() for out in outputs for f in _meta_filters(out) | |
| } | |
| # The values that actually RAN, per column, not the intended ones: the label | |
| # must never claim more than the data (replay of gate run 2: a comparison | |
| # whose second side the plan never fetched would otherwise read "SDJ, IPR"). | |
| ran: dict[str, list[str]] = {} | |
| for out in outputs: | |
| for f in _meta_filters(out): | |
| col = str(f.get("column", "")).lower() | |
| if col in eff_cols and f.get("op") in _EQ_OPS: | |
| bucket = ran.setdefault(col, []) | |
| for v in _filter_values(f.get("value")): | |
| if _norm(v) not in {_norm(b) for b in bucket}: | |
| bucket.append(v) | |
| excluded: dict[str, list[str]] = {} | |
| for out in outputs: | |
| for f in _meta_filters(out): | |
| col = str(f.get("column", "")).lower() | |
| if col in eff_cols and f.get("op") in _NEG_OPS: | |
| excluded.setdefault(col, []).extend(_filter_values(f.get("value"))) | |
| parts: list[str] = [] | |
| not_applied = list(scope.not_applied) | |
| partial = {p.lower() for p in scope.partial} | |
| for entry in effective: | |
| shown = entry | |
| if entry.values is not None and ran.get(entry.column.lower()): | |
| shown = ScopeEntry(column=entry.column, values=ran[entry.column.lower()]) | |
| text = _render_entry(shown, id_) | |
| if excluded.get(entry.column.lower()): | |
| ex = ", ".join(_dedupe(excluded[entry.column.lower()])) | |
| text += f" kecuali {ex}" if id_ else f" except {ex}" | |
| if entry.column.lower() in partial: | |
| text += " (sebagian kueri)" if id_ else " (some queries)" | |
| if entry.values is not None and entry.column.lower() not in filtered_cols: | |
| text += " (tidak diterapkan)" if id_ else " (not applied)" | |
| if not any(n.lower().startswith(entry.column.lower() + ":") for n in not_applied): | |
| not_applied.append(entry.column) | |
| parts.append(text) | |
| others: list[str] = [] | |
| for out in outputs: | |
| for f in _meta_filters(out): | |
| if str(f.get("column", "")).lower() in eff_cols: | |
| continue | |
| rendered = _render_filter(f) | |
| if rendered not in others: | |
| others.append(rendered) | |
| parts.extend(others[:_MAX_OTHER_FILTERS]) | |
| coverage = _coverage(outputs) | |
| if coverage: | |
| parts.append(coverage) | |
| if scope.focus and focus_changed(scope.focus, effective, set(scope.lifted)): | |
| rendered_focus = "; ".join(_render_entry(e, id_) for e in scope.focus) | |
| parts.append( | |
| f"di luar fokus ({rendered_focus})" if id_ else f"outside focus ({rendered_focus})" | |
| ) | |
| unknown: list[str] = [] | |
| for out in outputs: | |
| meta = getattr(out, "meta", None) or {} | |
| filters = _meta_filters(out) | |
| if ( | |
| meta.get("row_count") == 0 | |
| and filters | |
| and all(str(f.get("column", "")).lower() in eff_cols for f in filters) | |
| ): | |
| for f in filters: | |
| unknown.extend(_filter_values(f.get("value"))) | |
| # Only a value the user TYPED can be "not found": an empty slice of the focus | |
| # is "no data", never "code SDJ not found" (review: a unit outside the focus | |
| # reported the focus's own codes as missing). | |
| typed = {_norm(t) for t in scope.typed} | |
| unknown = [u for u in _dedupe(unknown) if _norm(u) in typed] | |
| not_applied_word = "tidak diterapkan" if id_ else "not applied" | |
| notes = [f"{n} ({not_applied_word})" for n in _dedupe(scope.not_applied)] | |
| if unknown: | |
| notes.extend( | |
| (f"kode {u} tidak ditemukan" if id_ else f"code {u} not found") for u in unknown | |
| ) | |
| elif outputs and all( | |
| (getattr(o, "meta", None) or {}).get("row_count") == 0 for o in outputs | |
| ): | |
| notes.append("tidak ada data untuk cakupan ini" if id_ else "no rows for this scope") | |
| lines: list[str] = [] | |
| if not effective and not outputs: | |
| parts, notes = [], [] | |
| if parts: | |
| lines.append(f"_{'Cakupan' if id_ else 'Scope'}: " + " · ".join(parts) + "_") | |
| if notes: | |
| lines.append(f"_{'Catatan' if id_ else 'Note'}: " + "; ".join(notes) + "_") | |
| return scope.model_copy( | |
| update={ | |
| "line": "\n".join(lines), | |
| "not_applied": _dedupe(not_applied), | |
| "unknown_values": unknown, | |
| }, | |
| deep=True, | |
| ) | |
| except Exception as e: | |
| logger.warning( | |
| "scope_render_failed", extra={"error": repr(e), "degraded_seam": "scope_render"} | |
| ) | |
| return scope | |