Rifqi Hafizuddin Claude Opus 5.5 commited on
Commit
621aed1
·
1 Parent(s): ae72d12

[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 CHANGED
@@ -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(
src/agents/scope/engine.py CHANGED
@@ -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
- if col in eff_cols or _stem(col) in eff_stems:
 
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
src/agents/scope/period.py CHANGED
@@ -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
- def turn_window(user_text: str, objective: str) -> tuple[Window | None, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- - they name a period the parser does not resolve ("6 bulan terakhir", "dibanding
203
- tahun lalu", two years) → None: the planner keeps it ("released");
 
 
 
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:
src/agents/slow_path/coordinator.py CHANGED
@@ -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=data_gap_message(reason, reply_language),
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=[],