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 +29 -3
- src/config/prompts/assembler.md +3 -0
- src/tools/data_access.py +34 -0
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]} ({
|
| 168 |
-
f"{_ranked_text(ranked)} ·
|
| 169 |
-
f" ·
|
| 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]:
|