diff --git a/apps/backend/src/taxflow/services/agents/research.py b/apps/backend/src/taxflow/services/agents/research.py index d443352..0c94e26 100644 --- a/apps/backend/src/taxflow/services/agents/research.py +++ b/apps/backend/src/taxflow/services/agents/research.py @@ -289,6 +289,31 @@ def build_client_profile(client: dict | None) -> str: ) +def build_voice_steering(client: dict | None) -> str: + """Per-firm tone instruction from ``clients.voice_sample`` — the same + ``voice_sample`` field a firm sets once in Settings and that already + calibrates DraftAgent/document_graph's drafted memos and letters + (identical instruction wording, deliberately, for one consistent voice + across the whole product). + + Before this, a research answer's tone came only from the bare + ``business_type`` word in ``build_client_profile`` (e.g. "hospitality" + vs "dental") - an emergent side effect of word choice, not a deliberate + per-firm calibration, and it only fired for firms whose business_type + happened to nudge the model toward plainer language. Wiring the firm's + own explicit voice sample into the SAME conversational path documents + already use closes that gap (accountant audit round three, #13 - a + real, deliberate mechanism, applied inconsistently, not "maybe + accidental"). + """ + if not client: + return "" + voice_sample = client.get("voice_sample") + if not voice_sample: + return "" + return f'The firm describes its own voice like this - match this tone:\n"{voice_sample}"' + + # source_type enum (003_knowledge_chunks.sql): # ato_ruling, ato_determination, ato_pbr, legislation, court_decision, # ato_guide, ato_news. @@ -433,8 +458,9 @@ def _firm_profile_summary(client: dict) -> str | None: """A short, business-readable summary of the firm profile applied to the answer (Task C6) — surfaced on ``trace.firm.profile_summary`` so the "why this answer?" UI can say, in one line, how the firm's profile steered the - result. Built from the same ``clients`` fields ``build_client_profile`` uses - (business_type, state, firm_style). Returns None when there's nothing to say. + result. Built from the same ``clients`` fields ``build_client_profile``/ + ``build_voice_steering`` use (business_type, state, firm_style, + voice_sample). Returns None when there's nothing to say. """ parts: list[str] = [] business_type = client.get("business_type") @@ -451,6 +477,9 @@ def _firm_profile_summary(client: dict) -> str | None: keys = list(firm_style.keys())[:3] parts.append("firm style: " + ", ".join(str(k) for k in keys)) + if client.get("voice_sample"): + parts.append("firm voice sample applied") + return ". ".join(parts) or None @@ -461,7 +490,9 @@ def build_firm_profile_fragment(client: dict | None) -> dict: ``profile_applied`` mirrors whether ``build_client_profile`` produced any advisory profile block (so it also respects PROFILE_INJECTION_ENABLED); ``voice_applied`` is true when the client carries a non-empty ``firm_style`` - jsonb (the firm-voice highlights folded into the profile block). Returns an + jsonb OR a non-empty ``voice_sample`` (accountant audit round three, #13 - + voice_sample already calibrates drafted documents; this fragment now + reflects that it calibrates the conversational answer too). Returns an EMPTY dict when neither applies (or there is no client), so ``trace.firm`` stays absent unless there is real firm content — this fragment is MERGED with B's ``firm_items``/``firm_items_used`` fragment (disjoint keys) before @@ -472,7 +503,7 @@ def build_firm_profile_fragment(client: dict | None) -> dict: return {} profile_applied = bool(build_client_profile(client)) firm_style = client.get("firm_style") - voice_applied = isinstance(firm_style, dict) and bool(firm_style) + voice_applied = (isinstance(firm_style, dict) and bool(firm_style)) or bool(client.get("voice_sample")) if not (profile_applied or voice_applied): return {} return { @@ -1297,11 +1328,12 @@ async def _build_steering( UI can show how much conversational context steered the answer. """ profile = build_client_profile(client) + voice = build_voice_steering(client) history: list[dict] = [] if settings.SESSION_MEMORY_ENABLED and session_id: history = await self._load_session_history(client_id, session_id) session_block = build_session_block(history) - steering = "\n\n".join(part for part in (profile, session_block) if part) + steering = "\n\n".join(part for part in (profile, voice, session_block) if part) active_modules = client.get("active_modules") if client else None source_type_hint = derive_source_type_hint(question, active_modules) diff --git a/apps/backend/tests/test_personalisation.py b/apps/backend/tests/test_personalisation.py index 8f22c6d..0938d0f 100644 --- a/apps/backend/tests/test_personalisation.py +++ b/apps/backend/tests/test_personalisation.py @@ -16,7 +16,9 @@ from taxflow.services.agents.research import ( ResearchAgent, build_client_profile, + build_firm_profile_fragment, build_session_block, + build_voice_steering, derive_jurisdiction_hint, derive_source_type_hint, ) @@ -51,6 +53,45 @@ def test_profile_string_empty_for_no_client(): assert build_client_profile({}) == "" +# --- accountant audit round three, #13: voice_sample now steers research too -- + + +def test_voice_steering_includes_firm_voice_sample(): + client = {"voice_sample": "We talk to café owners like people, not accountants."} + voice = build_voice_steering(client) + assert "We talk to café owners like people, not accountants." in voice + assert "match this tone" in voice.lower() + + +def test_voice_steering_empty_when_no_sample(): + assert build_voice_steering({"business_type": "dental"}) == "" + assert build_voice_steering(None) == "" + assert build_voice_steering({}) == "" + + +def test_voice_steering_matches_draft_agent_wording(): + """Same firm, same voice, same literal instruction across both the + research answer and the drafted-document path (draft.py's + voice_instruction) - not two independently-tuned steering strings.""" + client = {"voice_sample": "Plain English, no jargon."} + voice = build_voice_steering(client) + assert voice == 'The firm describes its own voice like this - match this tone:\n"Plain English, no jargon."' + + +def test_firm_profile_fragment_voice_applied_from_voice_sample_alone(): + """voice_applied must reflect voice_sample even with no firm_style set - + firm_style is empty for every real seeded persona today, so voice_sample + is the actual signal that per-firm tone calibration is happening.""" + client = {"voice_sample": "Talk to my clients like real people."} + fragment = build_firm_profile_fragment(client) + assert fragment["voice_applied"] is True + assert "firm voice sample applied" in fragment["profile_summary"] + + +def test_firm_profile_fragment_no_voice_when_neither_set(): + assert build_firm_profile_fragment({"business_type": "dental"})["voice_applied"] is False + + @pytest.mark.asyncio async def test_profile_appears_in_generation_prompt(): """The advisory profile must reach the actual user message sent to the model.""" @@ -84,6 +125,37 @@ async def fake_generate(question, context, model, steering=""): assert content.index("dental") < content.index("Question:") +@pytest.mark.asyncio +async def test_voice_sample_reaches_generation_prompt(): + """A firm's voice_sample must reach the actual generation call for a + research answer, not just a drafted document (accountant audit round + three, #13).""" + agent = ResearchAgent() + client = {"voice_sample": "Talk to my clients like real people, not other accountants."} + + captured = {} + + async def fake_generate(question, context, model, steering=""): + captured["steering"] = steering + return "Answer [1]", { + "input_tokens": 1, "output_tokens": 1, + "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, + } + + strong_chunks = [ + {"id": str(i), "citation": f"c{i}", "content": "x", "source_url": "", "score": 0.5} + for i in range(6) + ] + with patch.object( + agent, "_retrieve_context", + new=AsyncMock(return_value=(strong_chunks, {"num_chunks": 6, "top_score": 0.5, "insufficient": False})), + ), patch.object(agent, "_generate", new=fake_generate): + await agent.run(question="q", client_id="cid", client=client) + + assert "Talk to my clients like real people, not other accountants." in captured["steering"] + assert "match this tone" in captured["steering"].lower() + + # --- Task D2: source_types SOFT boost, not a hard filter ----------------------- diff --git a/apps/dashboard/app/dashboard/settings/page.tsx b/apps/dashboard/app/dashboard/settings/page.tsx index 616e161..70f8c72 100644 --- a/apps/dashboard/app/dashboard/settings/page.tsx +++ b/apps/dashboard/app/dashboard/settings/page.tsx @@ -216,7 +216,7 @@ export default function SettingsPage() { rows={3} value={settings.voice_sample ?? ""} onChange={(e) => setSettings({ ...settings, voice_sample: e.target.value })} - placeholder="Used to calibrate the tone of drafted advice memos and letters." + placeholder="Used to calibrate the tone of research answers, drafted advice memos, and letters." />