Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions apps/backend/src/taxflow/services/agents/research.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand All @@ -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


Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions apps/backend/tests/test_personalisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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 -----------------------


Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/app/dashboard/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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."
/>
</div>

Expand Down
Loading