diff --git a/backend/apps/agents/browser/browser_map_reduce_read.py b/backend/apps/agents/browser/browser_map_reduce_read.py index 9fb548c8..a90a64ca 100644 --- a/backend/apps/agents/browser/browser_map_reduce_read.py +++ b/backend/apps/agents/browser/browser_map_reduce_read.py @@ -16,6 +16,7 @@ import asyncio import json import logging import os +import re import time from backend.apps.agents.browser import browser_fast_read as fr @@ -53,6 +54,60 @@ def enabled() -> bool: return os.environ.get("OSW_MAP_REDUCE_READ", "1") != "0" +# The aux reduce got the VALUES right but flipped the arithmetic twice in ~10 live runs ("taller +# by 360.2m" beside its own 113.2 math; "1096-1636=-540, not older" beside "540 years older"), so +# for the two shapes that are pure arithmetic the number is computed HERE and the model never +# does subtraction. Anything unparseable falls open to the aux reduce. +P_DIFF_RE = re.compile(r"\b(difference|older|younger|taller|shorter|higher|lower|farther|further|longer|heavier|lighter|bigger|smaller|faster|slower)\b", re.I) +P_SUM_RE = re.compile(r"\b(combined|total|sum|together|altogether)\b", re.I) +P_VALUE_RE = re.compile(r"VALUE:\s*([0-9]+(?:\.[0-9]+)?)\s*([a-zA-Z%]*)", re.I) +P_VALUE_LINE = ( + "\nEnd with one extra line: VALUE: ." +) + + +def op_for(prompt: str) -> str: + """'difference' | 'sum' | '' from the request's own wording; '' = aux reduce as before.""" + low = prompt or "" + if P_SUM_RE.search(low): + return "sum" + if P_DIFF_RE.search(low): + return "difference" + return "" + + +def fmt_num(n: float) -> str: + """Human numbers: 35,842,039 not 3.5842e+07; two decimals max on non-integers.""" + return f"{n:,.0f}" if float(n).is_integer() else f"{n:,.2f}" + + +def computed_answer(op: str, plan: list[tuple[str, str]], subs: list) -> str: + """The deterministic answer when every sub-answer carries a parseable VALUE in agreeing + units; '' means fall open to the aux reduce. States both values and the computed number, + and deliberately asserts NO direction prose (that is exactly what the aux got wrong).""" + vals: list[tuple[float, str]] = [] + for s in subs: + m = P_VALUE_RE.search(s or "") + if not m: + return "" + vals.append((float(m.group(1)), m.group(2).lower())) + units = {u for _, u in vals} + if len(units) > 1: + return "" + unit = f" {vals[0][1]}" if vals[0][1] else "" + shown = "\n".join(f"- {q}: {fmt_num(v)}{unit}" for (q, _), (v, _) in zip(plan, vals)) + if op == "difference" and len(vals) == 2: + n = abs(vals[0][0] - vals[1][0]) + return (f"**Answer: {fmt_num(n)}{unit}**\n\n{shown}\n" + f"(computed: |{fmt_num(vals[0][0])} - {fmt_num(vals[1][0])}| = {fmt_num(n)})") + if op == "sum": + n = sum(v for v, _ in vals) + return (f"**Answer: {fmt_num(n)}{unit}**\n\n{shown}\n" + f"(computed: {' + '.join(fmt_num(v) for v, _ in vals)} = {fmt_num(n)})") + return "" + + def parse_plan(text: str) -> list[tuple[str, str]]: """(question, url) pairs from the decompose JSON; [] on anything unparseable or single-source. Bounded to P_MAX_SOURCES so a runaway plan can't fan out.""" @@ -73,10 +128,11 @@ def parse_plan(text: str) -> list[tuple[str, str]]: return out[:P_MAX_SOURCES] -async def p_fetch_and_extract(client, aux_model: str, q: str, url: str) -> str | None: +async def p_fetch_and_extract(client, aux_model: str, q: str, url: str, ask_value: bool) -> str | None: """One source: fetch the page, aux-extract the answer to q, or None if the page is thin or insufficient (so the whole map-reduce fails open, never - fabricates a missing piece).""" + fabricates a missing piece). ask_value appends the machine-parseable VALUE + line the code-side arithmetic needs.""" try: raw = await fr.fetch_raw(url) text = fr.strip_tags(raw) @@ -85,7 +141,7 @@ async def p_fetch_and_extract(client, aux_model: str, q: str, url: str) -> str | if fr.page_is_thin(text): return None ans = await fr.ask_aux( - client, aux_model, fr.P_ANSWER_SYSTEM, + client, aux_model, fr.P_ANSWER_SYSTEM + (P_VALUE_LINE if ask_value else ""), f"Request: {q}\n\nPage text from {url}:\n{text[:fr.P_MAX_PAGE_CHARS]}") if not ans or ans.upper().startswith("INSUFFICIENT"): return None @@ -114,12 +170,20 @@ async def try_map_reduce_read(prompt: str, brief: str, settings, primary_api: st return None logger.info(f"[browser-mapreduce] {len(plan)} sources: {[u for _, u in plan]}") - subs = await asyncio.gather(*[p_fetch_and_extract(client, aux_model, q, u) for q, u in plan]) + p_op = op_for(prompt) + subs = await asyncio.gather(*[p_fetch_and_extract(client, aux_model, q, u, bool(p_op)) for q, u in plan]) if any(s is None for s in subs): logger.info(f"[browser-mapreduce] a source came back thin/insufficient in " f"{int((time.monotonic() - t0) * 1000)}ms; browser fallback") return None + if p_op: + p_coded = computed_answer(p_op, plan, subs) + if p_coded: + logger.info(f"[browser-mapreduce] {p_op} computed in code from {len(plan)} sources " + f"in {int((time.monotonic() - t0) * 1000)}ms") + return f"{p_coded}\n\n(Sources: {', '.join(u for _, u in plan)})" + joined = "\n\n".join(f"Sub-question: {q}\nAnswer (from {u}): {s}" for (q, u), s in zip(plan, subs)) final = await fr.ask_aux(client, aux_model, P_REDUCE_SYSTEM,