From 62f761eca05c90bf25a8e1003bd523418c0d55c0 Mon Sep 17 00:00:00 2001 From: Ahmed Janabi Date: Sun, 30 Aug 2026 20:23:05 +0000 Subject: [PATCH] feat: wire lens into research prompts, closing R38 (lens-axis) --- council/researcher.py | 3 +++ docs/GLOSSARY.md | 3 ++- tests/test_researcher_lens.py | 46 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/test_researcher_lens.py diff --git a/council/researcher.py b/council/researcher.py index fac2352..781b547 100644 --- a/council/researcher.py +++ b/council/researcher.py @@ -31,6 +31,7 @@ from council.research import (extract_date_hint, fetch_extract, inject_recency, is_breaking, is_time_sensitive, order_by_recency, route_engines, search_structured) +from council.worker import LENSES def _num_env(key: str, default, cast): @@ -161,9 +162,11 @@ def _draft(self, brief: str, evidence: list[dict], local: list[dict] | None = No cite = " and ".join(p for p in ( ("[S#] for web sources" if evidence else ""), ("[L#] for your documents" if local else "")) if p) or "[S#]" + lens_instruction = LENSES.get(self.lens, LENSES["neutral"]) return self._generate( f"{role} Today is {self._today()}. Using ONLY the sources below, " "write your findings on the brief:\n" + f" • {lens_instruction}\n" " • Lead with the most decision-relevant findings; concrete numbers and dates.\n" " • CURRENCY MATTERS: when sources span time or conflict, trust the MOST RECENT " "(sources are listed with their dates, freshest first), and STATE the date of any " diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 5ff7fbc..754756c 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -49,7 +49,8 @@ uses the old names in its older entries. work over time while leaving ungated work open to newcomers. - **Lens** — an angle of attack given to an analyst/node via its prompt (e.g. `opportunity`, `skeptic`, `first_principles`, `practical`) to pull different ideas - from the same or different models. + from the same or different models (applies uniformly to both chat jobs, `pw ask`, + and research jobs, `pw research`/network research jobs — R38). - **Model-downgrade** — the real cheat under a non-cash credit: a node silently running a smaller or more-quantized model than it claims, to earn credit for less work. - **Cross-hardware verification** — confirming that honest answers from different diff --git a/tests/test_researcher_lens.py b/tests/test_researcher_lens.py new file mode 100644 index 0000000..6f7094b --- /dev/null +++ b/tests/test_researcher_lens.py @@ -0,0 +1,46 @@ +"""R38 (lens-axis) — lens now drives the research-job prompt too, not just chat's +council/worker.py::PerspectiveWorker.answer(). Mirrors that file's own +LENSES.get(self.lens, LENSES["neutral"]) fallback pattern in +ResearchWorker._draft(), and locks in backward compatibility for +council/local.py, which hardcodes lens="independent analyst" for single-player +`pw research` — that string is not a LENSES key, so it must safely fall +through to the neutral instruction rather than raising.""" +import council.researcher as RW +from council.worker import LENSES + +EVIDENCE = [{"title": "T", "url": "https://x.test/1", "host": "x.test", + "snippet": "s", "date_hint": ""}] + + +def _captured_prompt(monkeypatch, lens): + captured = {} + + def fake_generate(self, prompt, num_predict): + captured["prompt"] = prompt + return "draft [S1].", 5 + + monkeypatch.setattr(RW.ResearchWorker, "_generate", fake_generate) + w = RW.ResearchWorker(worker_id="m", model="m", lens=lens) + w._draft("a brief about basic chemistry facts", EVIDENCE) + return captured["prompt"] + + +def test_draft_prompt_carries_skeptic_lens(monkeypatch): + prompt = _captured_prompt(monkeypatch, "skeptic") + assert LENSES["skeptic"] in prompt + + +def test_draft_prompt_carries_neutral_lens_by_default(monkeypatch): + # default ResearchWorker.lens == "neutral" — same backward-compat text as worker.py + prompt = _captured_prompt(monkeypatch, "neutral") + assert LENSES["neutral"] in prompt + + +def test_draft_prompt_falls_back_to_neutral_for_local_py_lens_string(monkeypatch): + # council/local.py hardcodes lens="independent analyst" for single-player `pw + # research` — not a LENSES key. Must not raise, and must fall through to the + # same neutral instruction LENSES.get(..., LENSES["neutral"]) uses everywhere + # else. This is the regression test guaranteeing local.py keeps working + # unmodified (council/local.py is intentionally untouched by R38). + prompt = _captured_prompt(monkeypatch, "independent analyst") + assert LENSES["neutral"] in prompt