[NOTICKET] feat(scope): relative periods resolved against today and applied by code (P0-5)
Browse files"6 bulan terakhir" ran as week 1–44 (the whole year); the planner.md rule was
prose only. period.relative_window (D7, WIB): "N bulan terakhir" = the N
complete months before today's month; bulan lalu/ini, tahun lalu/ini, kuartal
lalu/ini. Released to the planner: windows across two years, period
comparisons (YoY, "dibanding tahun lalu"), "N bulan lalu", open ranges, weekly.
A period comparison releases even when one side parses (review round 3).
Comparisons against a target or between sites keep the window. All-time wording
("selama ini", "seluruh data", "ever") releases. The scope hook takes `today`;
the planner eval passes each case's date. Scope line hides only `*_id` next to
its code column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- eval/planner/run_eval.py +2 -1
- src/agents/scope/engine.py +2 -1
- src/agents/scope/period.py +124 -4
- src/agents/slow_path/coordinator.py +12 -3
|
@@ -458,7 +458,8 @@ async def _run_case(catalog: Any, tools: Any, n2id: dict, case: dict, **plan_kw:
|
|
| 458 |
user_message=case["question"], value_index=_fixture_value_index(catalog)
|
| 459 |
)
|
| 460 |
plan_kw["prepare"] = SlowPathCoordinator._scope_hook(
|
| 461 |
-
context, catalog, inputs, None, case["question"], {}
|
|
|
|
| 462 |
)
|
| 463 |
try:
|
| 464 |
task_list = await planner.plan(
|
|
|
|
| 458 |
user_message=case["question"], value_index=_fixture_value_index(catalog)
|
| 459 |
)
|
| 460 |
plan_kw["prepare"] = SlowPathCoordinator._scope_hook(
|
| 461 |
+
context, catalog, inputs, None, case["question"], {},
|
| 462 |
+
today=plan_kw.get("today"), # P0-5: relative periods resolve on the case's date
|
| 463 |
)
|
| 464 |
try:
|
| 465 |
task_list = await planner.plan(
|
|
@@ -1144,7 +1144,8 @@ def render_scope_line(
|
|
| 1144 |
for f in _meta_filters(out):
|
| 1145 |
col = str(f.get("column", "")).lower()
|
| 1146 |
# `site_id 2009` is the resolved id of `site_code IPR`, already shown (P0-4).
|
| 1147 |
-
|
|
|
|
| 1148 |
continue
|
| 1149 |
rendered = _render_filter(f)
|
| 1150 |
bucket = period_parts if col in _PERIOD_COLUMNS else others
|
|
|
|
| 1144 |
for f in _meta_filters(out):
|
| 1145 |
col = str(f.get("column", "")).lower()
|
| 1146 |
# `site_id 2009` is the resolved id of `site_code IPR`, already shown (P0-4).
|
| 1147 |
+
# Only `*_id` columns: a `unit_no` next to `unit_name` may disagree.
|
| 1148 |
+
if col in eff_cols or (col.endswith("_id") and _stem(col) in eff_stems):
|
| 1149 |
continue
|
| 1150 |
rendered = _render_filter(f)
|
| 1151 |
bucket = period_parts if col in _PERIOD_COLUMNS else others
|
|
@@ -121,7 +121,9 @@ _OWN_PERIOD = re.compile(
|
|
| 121 |
r"by year|per year)\b|"
|
| 122 |
r"\b(awal|akhir|pertengahan)\s+(tahun|bulan)\b|"
|
| 123 |
r"\b(semua|seluruh)\s+(tahun|periode)\b|\bkeseluruhan data\b|\bsepanjang waktu\b|"
|
| 124 |
-
r"\ball (years|time|periods)\b|\btanggal\s+\d+|\bdalam\s+\d+\s+(tahun|bulan)\b"
|
|
|
|
|
|
|
| 125 |
re.IGNORECASE,
|
| 126 |
)
|
| 127 |
_INT_TYPES = ("int", "integer")
|
|
@@ -192,23 +194,141 @@ def names_own_period(text: str) -> bool:
|
|
| 192 |
return True
|
| 193 |
|
| 194 |
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
"""The window code applies this turn, and where it came from.
|
| 197 |
|
| 198 |
- the user's own words name a clean window ("Juni–September 2026") → that window:
|
| 199 |
Sofhia's analysis a67625f2 typed it and the planner still filtered month 6–9 with
|
| 200 |
no year, reading June–September of every year (records #3, #6);
|
| 201 |
- they name months but no year ("Januari–Juli") → those months in the objective's year;
|
| 202 |
-
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
| 204 |
- they name no period → the objective's window (None if it has none).
|
| 205 |
Never raises.
|
| 206 |
"""
|
| 207 |
try:
|
| 208 |
obj = parse_window(objective)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
own = parse_window(user_text)
|
| 210 |
if own is not None:
|
| 211 |
return own, "question"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
if obj is not None and _month_numbers(user_text) and not _years(user_text):
|
| 213 |
merged = parse_window(f"{user_text} {obj.year}")
|
| 214 |
if merged is not None:
|
|
|
|
| 121 |
r"by year|per year)\b|"
|
| 122 |
r"\b(awal|akhir|pertengahan)\s+(tahun|bulan)\b|"
|
| 123 |
r"\b(semua|seluruh)\s+(tahun|periode)\b|\bkeseluruhan data\b|\bsepanjang waktu\b|"
|
| 124 |
+
r"\ball (years|time|periods)\b|\btanggal\s+\d+|\bdalam\s+\d+\s+(tahun|bulan)\b|"
|
| 125 |
+
r"\b(ever|historically|all data|historis|selama ini|sepanjang masa|"
|
| 126 |
+
r"(semua|seluruh)\s+data(\s+yang\s+ada)?)\b|\b\d+\s+(days?|weeks?|months?|years?)\s+ago\b",
|
| 127 |
re.IGNORECASE,
|
| 128 |
)
|
| 129 |
_INT_TYPES = ("int", "integer")
|
|
|
|
| 194 |
return True
|
| 195 |
|
| 196 |
|
| 197 |
+
# Relative periods (audit P0-5, D7: counted back from TODAY, WIB). "6 bulan terakhir"
|
| 198 |
+
# ran as `week 1–44` (the whole year) on analysis 2d841e2f; the planner.md rule (N
|
| 199 |
+
# complete months before today's month) was prose only.
|
| 200 |
+
_NUM_WORDS = {
|
| 201 |
+
"satu": 1, "dua": 2, "tiga": 3, "empat": 4, "lima": 5, "enam": 6, "tujuh": 7,
|
| 202 |
+
"delapan": 8, "sembilan": 9, "sepuluh": 10, "sebelas": 11, "dua belas": 12,
|
| 203 |
+
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7,
|
| 204 |
+
"eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12,
|
| 205 |
+
}
|
| 206 |
+
_N = r"(\d{1,2}|" + "|".join(sorted(_NUM_WORDS, key=len, reverse=True)) + r")"
|
| 207 |
+
_LAST_N_MONTHS = re.compile(
|
| 208 |
+
rf"\b{_N}\s+bulan\s+(?:terakhir|ke\s*belakang|belakangan)\b|"
|
| 209 |
+
rf"\b(?:last|past|previous)\s+{_N}\s+months?\b",
|
| 210 |
+
re.IGNORECASE,
|
| 211 |
+
)
|
| 212 |
+
_PREV_MONTH = re.compile(r"\b(bulan\s+(?:lalu|kemarin|sebelumnya)|last month|previous month)\b",
|
| 213 |
+
re.IGNORECASE)
|
| 214 |
+
_THIS_MONTH = re.compile(r"\b(bulan\s+ini|this month)\b", re.IGNORECASE)
|
| 215 |
+
_THIS_YEAR = re.compile(r"\b(tahun\s+ini|this year)\b", re.IGNORECASE)
|
| 216 |
+
_PREV_YEAR = re.compile(r"\b(tahun\s+(?:lalu|kemarin|sebelumnya)|last year|previous year)\b",
|
| 217 |
+
re.IGNORECASE)
|
| 218 |
+
_PREV_QUARTER = re.compile(r"\b((?:kuartal|triwulan)\s+(?:lalu|sebelumnya)|last quarter|"
|
| 219 |
+
r"previous quarter)\b", re.IGNORECASE)
|
| 220 |
+
_THIS_QUARTER = re.compile(r"\b((?:kuartal|triwulan)\s+ini|this quarter)\b", re.IGNORECASE)
|
| 221 |
+
# A comparison BETWEEN PERIODS (review 2026-11-03): the comparison word near a period
|
| 222 |
+
# word, or a year-over-year phrase. "dibandingkan target 85%" and "IPR vs ADT" compare
|
| 223 |
+
# something else and keep the window.
|
| 224 |
+
_COMPARE = re.compile(
|
| 225 |
+
r"\b(dibanding\w*|banding\w*|vs\.?|versus|compared?(?:\s+(?:to|with))?)\s+"
|
| 226 |
+
r"(?:\S+\s+){0,3}?(periode|tahun|bulan|kuartal|triwulan|minggu|sebelumnya|"
|
| 227 |
+
r"(?:the\s+)?(?:last|previous|prior|same)\s+(?:year|month|quarter|week|period)|"
|
| 228 |
+
r"year|month|quarter|week)\b|"
|
| 229 |
+
r"\b(yoy|year[- ]over[- ]year|tahun ke tahun|periode yang sama|same period)\b",
|
| 230 |
+
re.IGNORECASE,
|
| 231 |
+
)
|
| 232 |
+
# "3 bulan lalu" is ONE month three months back, not "bulan lalu"; "2 tahun lalu" is
|
| 233 |
+
# not last year; "3 months ago" — ambiguous windows, left to the planner.
|
| 234 |
+
_N_AGO = re.compile(
|
| 235 |
+
rf"\b{_N}\s+(bulan|tahun|minggu|pekan|kuartal|triwulan)\s+(lalu|sebelumnya|kemarin)\b|"
|
| 236 |
+
rf"\b{_N}\s+(months?|years?|weeks?|quarters?)\s+ago\b",
|
| 237 |
+
re.IGNORECASE,
|
| 238 |
+
)
|
| 239 |
+
# An open range from a relative point ("sejak tahun lalu", "dari bulan lalu sampai
|
| 240 |
+
# sekarang") runs to today — across a year boundary, so left to the planner.
|
| 241 |
+
_OPEN_RANGE = re.compile(
|
| 242 |
+
r"\b(sejak|dari|mulai|since|from)\s+(tahun|bulan|kuartal|triwulan|last|the last)\b|"
|
| 243 |
+
r"\b(sampai|hingga)\s+(sekarang|saat ini|kini)\b|\b(until now|to date|so far)\b",
|
| 244 |
+
re.IGNORECASE,
|
| 245 |
+
)
|
| 246 |
+
UNRESOLVED = "unresolved"
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _n(token: str) -> int | None:
|
| 250 |
+
t = token.lower()
|
| 251 |
+
return int(t) if t.isdigit() else _NUM_WORDS.get(t)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def relative_window(text: str, today: _dt.date) -> Window | str | None:
|
| 255 |
+
"""A relative period in `text` resolved against `today`.
|
| 256 |
+
|
| 257 |
+
Window — resolved inside one calendar year; UNRESOLVED — a relative period that
|
| 258 |
+
spans two years, compares periods ("dibanding tahun lalu"), or is weekly (the
|
| 259 |
+
planner keeps it); None — no relative period. "N bulan terakhir" = the N complete
|
| 260 |
+
months before today's month (planner.md's rule, now code).
|
| 261 |
+
"""
|
| 262 |
+
if not text:
|
| 263 |
+
return None
|
| 264 |
+
if _N_AGO.search(text):
|
| 265 |
+
return UNRESOLVED
|
| 266 |
+
y, m = today.year, today.month
|
| 267 |
+
found: list[Window | str] = []
|
| 268 |
+
hit = _LAST_N_MONTHS.search(text)
|
| 269 |
+
if hit:
|
| 270 |
+
n = _n(next(g for g in hit.groups() if g))
|
| 271 |
+
if n and 1 <= n <= 12:
|
| 272 |
+
found.append(Window(y, m - n, m - 1) if m - n >= 1 else UNRESOLVED)
|
| 273 |
+
else:
|
| 274 |
+
found.append(UNRESOLVED)
|
| 275 |
+
if _PREV_MONTH.search(text):
|
| 276 |
+
found.append(Window(y, m - 1, m - 1) if m > 1 else Window(y - 1, 12, 12))
|
| 277 |
+
if _THIS_MONTH.search(text):
|
| 278 |
+
found.append(Window(y, m, m))
|
| 279 |
+
if _PREV_YEAR.search(text):
|
| 280 |
+
found.append(Window(y - 1))
|
| 281 |
+
if _THIS_YEAR.search(text):
|
| 282 |
+
found.append(Window(y))
|
| 283 |
+
q = (m - 1) // 3 # 0-based current quarter
|
| 284 |
+
if _PREV_QUARTER.search(text):
|
| 285 |
+
found.append(Window(y, 3 * q - 2, 3 * q) if q > 0 else Window(y - 1, 10, 12))
|
| 286 |
+
if _THIS_QUARTER.search(text):
|
| 287 |
+
found.append(Window(y, 3 * q + 1, 3 * q + 3))
|
| 288 |
+
if re.search(r"\b\d+\s+(minggu|pekan)\s+terakhir\b|\blast\s+\d+\s+weeks?\b", text,
|
| 289 |
+
re.IGNORECASE):
|
| 290 |
+
found.append(UNRESOLVED)
|
| 291 |
+
if not found:
|
| 292 |
+
return None
|
| 293 |
+
if len(found) > 1 or _COMPARE.search(text) or _OPEN_RANGE.search(text):
|
| 294 |
+
return UNRESOLVED # two periods, a period comparison, or an open range
|
| 295 |
+
return found[0]
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def turn_window(
|
| 299 |
+
user_text: str, objective: str, today: _dt.date | None = None
|
| 300 |
+
) -> tuple[Window | None, str]:
|
| 301 |
"""The window code applies this turn, and where it came from.
|
| 302 |
|
| 303 |
- the user's own words name a clean window ("Juni–September 2026") → that window:
|
| 304 |
Sofhia's analysis a67625f2 typed it and the planner still filtered month 6–9 with
|
| 305 |
no year, reading June–September of every year (records #3, #6);
|
| 306 |
- they name months but no year ("Januari–Juli") → those months in the objective's year;
|
| 307 |
+
- a relative period inside one year ("6 bulan terakhir", "bulan lalu", "tahun ini")
|
| 308 |
+
→ resolved against `today` (D7; WIB by default);
|
| 309 |
+
- they name a period the parser does not resolve ("dibanding tahun lalu", a window
|
| 310 |
+
across two years, "8 minggu terakhir", two years) → None: the planner keeps it
|
| 311 |
+
("released");
|
| 312 |
- they name no period → the objective's window (None if it has none).
|
| 313 |
Never raises.
|
| 314 |
"""
|
| 315 |
try:
|
| 316 |
obj = parse_window(objective)
|
| 317 |
+
# A period comparison ("Juni–September 2026 dibanding tahun lalu", YoY) is the
|
| 318 |
+
# planner's to build, even when one side parses: applying that side by code
|
| 319 |
+
# would collapse a one-query comparison into one period (review 2026-11-03).
|
| 320 |
+
if _COMPARE.search(user_text or ""):
|
| 321 |
+
return None, "released"
|
| 322 |
own = parse_window(user_text)
|
| 323 |
if own is not None:
|
| 324 |
return own, "question"
|
| 325 |
+
if today is None:
|
| 326 |
+
today = _dt.datetime.now(_dt.timezone(_dt.timedelta(hours=7))).date()
|
| 327 |
+
rel = relative_window(user_text, today)
|
| 328 |
+
if isinstance(rel, Window):
|
| 329 |
+
return rel, "relative"
|
| 330 |
+
if rel == UNRESOLVED:
|
| 331 |
+
return None, "released"
|
| 332 |
if obj is not None and _month_numbers(user_text) and not _years(user_text):
|
| 333 |
merged = parse_window(f"{user_text} {obj.year}")
|
| 334 |
if merged is not None:
|
|
@@ -11,7 +11,7 @@ See AGENT_ARCHITECTURE_CONTEXT_new.md §5.2 / §6.1.
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
from collections.abc import Awaitable, Callable
|
| 14 |
-
from datetime import UTC, datetime
|
| 15 |
from typing import Any
|
| 16 |
|
| 17 |
from src.middlewares.logging import get_logger
|
|
@@ -145,10 +145,14 @@ class SlowPathCoordinator:
|
|
| 145 |
scope_values: Awaitable[dict[str, list[str]]] | None,
|
| 146 |
query: str,
|
| 147 |
held: dict[str, Any],
|
|
|
|
| 148 |
) -> Callable[[TaskList], Awaitable[None]]:
|
| 149 |
"""The planner's `prepare` hook: resolve this turn's scope and apply it to every
|
| 150 |
retrieve_data of each attempt, BEFORE validation (2026-10-29).
|
| 151 |
|
|
|
|
|
|
|
|
|
|
| 152 |
Before validation because the planner's own site handling was what failed:
|
| 153 |
gate run 2 lost 6 of 31 turns to typed ids and made-up placeholders that code
|
| 154 |
can replace with the right join. The value lists (`values.py`) load alongside
|
|
@@ -201,7 +205,7 @@ class SlowPathCoordinator:
|
|
| 201 |
turn_window,
|
| 202 |
)
|
| 203 |
|
| 204 |
-
window, origin = turn_window(scope.user_message or query, objective)
|
| 205 |
if (
|
| 206 |
window is not None
|
| 207 |
and not plan_forecasts(task_list)
|
|
@@ -321,8 +325,13 @@ def _infeasible_output(
|
|
| 321 |
) -> AssembledOutput:
|
| 322 |
"""Build the data-gap answer + a faithful (non-substantive) record."""
|
| 323 |
reason = task_list.infeasible_reason or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
return AssembledOutput(
|
| 325 |
-
chat_answer=
|
| 326 |
analysis_record=AnalysisRecord(
|
| 327 |
goal_restated=task_list.goal_restated,
|
| 328 |
findings=[],
|
|
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
from collections.abc import Awaitable, Callable
|
| 14 |
+
from datetime import UTC, date, datetime
|
| 15 |
from typing import Any
|
| 16 |
|
| 17 |
from src.middlewares.logging import get_logger
|
|
|
|
| 145 |
scope_values: Awaitable[dict[str, list[str]]] | None,
|
| 146 |
query: str,
|
| 147 |
held: dict[str, Any],
|
| 148 |
+
today: date | None = None,
|
| 149 |
) -> Callable[[TaskList], Awaitable[None]]:
|
| 150 |
"""The planner's `prepare` hook: resolve this turn's scope and apply it to every
|
| 151 |
retrieve_data of each attempt, BEFORE validation (2026-10-29).
|
| 152 |
|
| 153 |
+
`today` resolves relative periods (P0-5); None = today in WIB, as the planner's
|
| 154 |
+
own "Today's date" line. The planner eval passes its case's fixed date.
|
| 155 |
+
|
| 156 |
Before validation because the planner's own site handling was what failed:
|
| 157 |
gate run 2 lost 6 of 31 turns to typed ids and made-up placeholders that code
|
| 158 |
can replace with the right join. The value lists (`values.py`) load alongside
|
|
|
|
| 205 |
turn_window,
|
| 206 |
)
|
| 207 |
|
| 208 |
+
window, origin = turn_window(scope.user_message or query, objective, today)
|
| 209 |
if (
|
| 210 |
window is not None
|
| 211 |
and not plan_forecasts(task_list)
|
|
|
|
| 325 |
) -> AssembledOutput:
|
| 326 |
"""Build the data-gap answer + a faithful (non-substantive) record."""
|
| 327 |
reason = task_list.infeasible_reason or ""
|
| 328 |
+
# A plan that failed its checks is not a data gap (P0-9): its own text, no lead.
|
| 329 |
+
answer = (
|
| 330 |
+
reason if getattr(task_list, "_exhausted", False) and reason
|
| 331 |
+
else data_gap_message(reason, reply_language)
|
| 332 |
+
)
|
| 333 |
return AssembledOutput(
|
| 334 |
+
chat_answer=answer,
|
| 335 |
analysis_record=AnalysisRecord(
|
| 336 |
goal_restated=task_list.goal_restated,
|
| 337 |
findings=[],
|