sofhiaazzhr Claude Opus 5.5 commited on
Commit
5398bb7
·
1 Parent(s): 8885f70

[NOTICKET] fix(assembler): a top-N list's ends are not the overall highest/lowest

Browse files

- 9 Nov re-test (Rifqi): for "10 lowest-PA units" the order line said "highest: <unit>", and the answer called it the highest PA overall (83.743% vs a real 99.479%)
- retrieve_data meta now carries `cut` (limit, first sort column, direction)
- The order line marks a cut list ("10 rows shown — a cut list: the 10 lowest by avg_pa, not all rows") and names its ends "highest/lowest of these 10"; a limit not reached keeps the old wording
- assembler.md: a cut list's ends are never the overall highest/lowest
- Local chat_sim (5 turns, demo xlsx): 5/5 right, including "the highest in that list" and the MTBS site comparison (ADT 1st of 5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

src/agents/slow_path/prompt.py CHANGED
@@ -133,9 +133,14 @@ def _order_lines(output: ToolOutput) -> list[str]:
133
  - exactly one text label column, not a time column (a trend keeps the stub-period
134
  rules; a two-label table would rank across groups);
135
  - numeric columns only, not time/id-like, no missing values, not all equal.
 
 
 
 
136
  """
137
  try:
138
  columns, rows = output.columns or [], output.rows or []
 
139
  if output.tool != "retrieve_data" or not 2 <= len(rows) <= _MAX_ROWS:
140
  return []
141
  if any(len(row) != len(columns) for row in rows):
@@ -164,15 +169,36 @@ def _order_lines(output: ToolOutput) -> list[str]:
164
  zip(cols[label], values, strict=True), key=lambda pair: pair[1], reverse=True
165
  )
166
  lines.append(
167
- f"order by {columns[i]} ({len(rows)} rows, highest first): "
168
- f"{_ranked_text(ranked)} · highest: {_ends(ranked, ranked[0][1])}"
169
- f" · lowest: {_ends(ranked, ranked[-1][1])}"
170
  )
171
  return lines
172
  except Exception: # noqa: BLE001 — a render detail must never cost the answer
173
  return []
174
 
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  def _is_number(value: object) -> bool:
177
  if isinstance(value, bool) or not isinstance(value, numbers.Real):
178
  return False
 
133
  - exactly one text label column, not a time column (a trend keeps the stub-period
134
  rules; a two-label table would rank across groups);
135
  - numeric columns only, not time/id-like, no missing values, not all equal.
136
+
137
+ A cut list ("the 10 lowest": `limit` reached, or the executor truncated) says so
138
+ and names its ends "of these N" — on 9 Nov the top row of the 10 lowest-PA units
139
+ was reported as the highest PA overall.
140
  """
141
  try:
142
  columns, rows = output.columns or [], output.rows or []
143
+ scope, top, bottom = _cut_words(output, len(rows))
144
  if output.tool != "retrieve_data" or not 2 <= len(rows) <= _MAX_ROWS:
145
  return []
146
  if any(len(row) != len(columns) for row in rows):
 
169
  zip(cols[label], values, strict=True), key=lambda pair: pair[1], reverse=True
170
  )
171
  lines.append(
172
+ f"order by {columns[i]} ({scope}, highest first): "
173
+ f"{_ranked_text(ranked)} · {top}: {_ends(ranked, ranked[0][1])}"
174
+ f" · {bottom}: {_ends(ranked, ranked[-1][1])}"
175
  )
176
  return lines
177
  except Exception: # noqa: BLE001 — a render detail must never cost the answer
178
  return []
179
 
180
 
181
+ def _cut_words(output: ToolOutput, n: int) -> tuple[str, str, str]:
182
+ """(row-count text, top label, bottom label) — "of these N" when the list is cut.
183
+
184
+ Cut = the executor truncated, or the query's `limit` was reached (fewer rows than
185
+ the limit means every row came back). Without a known cut the words are as before.
186
+ """
187
+ meta = output.meta or {}
188
+ cut = meta.get("cut") if isinstance(meta.get("cut"), dict) else {}
189
+ limit = cut.get("limit")
190
+ is_cut = bool(meta.get("truncated")) or (isinstance(limit, int) and n >= limit)
191
+ if not is_cut:
192
+ return f"{n} rows", "highest", "lowest"
193
+ end = {"asc": "lowest", "desc": "highest"}.get(cut.get("dir") or "")
194
+ kept = f"the {n} {end} by {cut['column']}" if end and cut.get("column") else "a part"
195
+ return (
196
+ f"{n} rows shown — a cut list: {kept}, not all rows",
197
+ f"highest of these {n}",
198
+ f"lowest of these {n}",
199
+ )
200
+
201
+
202
  def _is_number(value: object) -> bool:
203
  if isinstance(value, bool) or not isinstance(value, numbers.Real):
204
  return False
src/config/prompts/assembler.md CHANGED
@@ -97,6 +97,9 @@ When no such section is present, write as you otherwise would.
97
  every count ("of the 5 sites") comes from that line, never from the row position in
98
  the table, which may be unsorted. No sentence may contradict it. If the user asks
99
  lowest first, reverse that order; do not re-rank.
 
 
 
100
  Example (made-up names): focus Site B; rows arrive as C 71.0, A 92.1, B 90.4 with
101
  `order by avg_uptime (3 rows, highest first): A 92.1 > B 90.4 > C 71.0`.
102
  Right: "Site B, the focus, is 2nd of 3 sites: below A, above C." Wrong: "B is the
 
97
  every count ("of the 5 sites") comes from that line, never from the row position in
98
  the table, which may be unsorted. No sentence may contradict it. If the user asks
99
  lowest first, reverse that order; do not re-rank.
100
+ When the line says `a cut list` (e.g. "the 10 lowest by pa, not all rows"), the
101
+ table is only part of the data: its ends are "highest/lowest **of these** N", never
102
+ the highest or lowest overall — say "the 10 lowest", not "the highest is …".
103
  Example (made-up names): focus Site B; rows arrive as C 71.0, A 92.1, B 90.4 with
104
  `order by avg_uptime (3 rows, highest first): A 92.1 > B 90.4 > C 71.0`.
105
  Right: "Site B, the focus, is 2nd of 3 sites: below A, above C." Wrong: "B is the
src/tools/data_access.py CHANGED
@@ -300,6 +300,10 @@ class DataAccessToolInvoker:
300
  # How each averaged measure was computed, for the scope line
301
  # (2026-11-09, RATIO_MEASURES_REQUIREMENTS M5).
302
  "methods": _method_summary(catalog, ir),
 
 
 
 
303
  },
304
  )
305
 
@@ -420,6 +424,36 @@ def _method_summary(catalog: Catalog, ir: QueryIR) -> list[dict[str, Any]]:
420
  return []
421
 
422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  def _pii_column_names(
424
  catalog: Catalog, ir: QueryIR, output_columns: list[str]
425
  ) -> list[str]:
 
300
  # How each averaged measure was computed, for the scope line
301
  # (2026-11-09, RATIO_MEASURES_REQUIREMENTS M5).
302
  "methods": _method_summary(catalog, ir),
303
+ # The row cut and the sort it kept, for the Assembler's order line
304
+ # (2026-11-09): "the 10 lowest" must not have its top row called
305
+ # the overall highest.
306
+ "cut": _cut_summary(catalog, ir),
307
  },
308
  )
309
 
 
424
  return []
425
 
426
 
427
+ def _cut_summary(catalog: Catalog, ir: QueryIR) -> dict[str, Any] | None:
428
+ """`{"limit", "column", "dir"}` when the query kept only the first N rows, else None.
429
+
430
+ `column` / `dir` are the first sort key as it appears in the result (a select
431
+ alias, else the catalog column name), or None without a sort. Never raises.
432
+ """
433
+ try:
434
+ if ir.limit is None:
435
+ return None
436
+ column: str | None = None
437
+ direction: str | None = None
438
+ if ir.order_by:
439
+ key = ir.order_by[0]
440
+ direction = key.dir
441
+ aliases = {
442
+ getattr(item, "alias", None) for item in ir.select
443
+ } - {None}
444
+ if key.column_id in aliases:
445
+ column = key.column_id
446
+ else:
447
+ names = {
448
+ c.column_id: c.name
449
+ for s in catalog.sources for t in s.tables for c in t.columns
450
+ }
451
+ column = names.get(key.column_id, key.column_id)
452
+ return {"limit": ir.limit, "column": column, "dir": direction}
453
+ except Exception:
454
+ return None
455
+
456
+
457
  def _pii_column_names(
458
  catalog: Catalog, ir: QueryIR, output_columns: list[str]
459
  ) -> list[str]: