Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@

from backend.api.features.onboarding_dump.models import SuggestedPrompt
from backend.api.features.onboarding_dump.parsing import parse_response_json
from backend.api.features.onboarding_dump.providers import (
known_providers,
provider_lines,
)
from backend.util.clients import get_openai_client
from backend.util.settings import Settings

Expand Down Expand Up @@ -140,6 +144,7 @@ async def generate_intro(transcript: str) -> tuple[str, list[SuggestedPrompt]]:
return fallback_intro(text)

instructions = await _fetch_langfuse_prompt() or _LOCAL_PROMPT
content = f"{_integrations_block()}{instructions}{text}"
Comment thread
Abhi1992002 marked this conversation as resolved.
Comment thread
Abhi1992002 marked this conversation as resolved.
Comment thread
Abhi1992002 marked this conversation as resolved.
Comment thread
Abhi1992002 marked this conversation as resolved.
Comment thread
Abhi1992002 marked this conversation as resolved.
data = None
# Two attempts: at temperature 0.6 an occasional generation comes back
# truncated or malformed, and one retry is far cheaper than shipping
Expand All @@ -149,7 +154,7 @@ async def generate_intro(transcript: str) -> tuple[str, list[SuggestedPrompt]]:
response = await asyncio.wait_for(
client.chat.completions.create(
model=_MODEL,
messages=[{"role": "user", "content": f"{instructions}{text}"}],
messages=[{"role": "user", "content": content}],
temperature=0.6,
max_tokens=3000,
),
Expand Down Expand Up @@ -181,6 +186,25 @@ async def generate_intro(transcript: str) -> tuple[str, list[SuggestedPrompt]]:
return greeting.strip()[:MAX_GREETING_CHARS], prompts[:MAX_PROMPTS]


def _integrations_block() -> str:
"""The live provider registry, prepended to the instructions.

Without it the model invents the tools it promises to wire up, and the
user's first suggested automation is one we cannot run. Goes in front
of the instructions (which end with ``Transcript:``) so the whole
static half of the message stays a stable prefix.
"""
lines = provider_lines(known_providers())
if not lines:
return ""
return (
"These are the integrations this platform can connect to. Only "
"promise automations that these can actually carry out, and never "
"name a tool that is not on this list:\n"
f"{lines}\n\n"
)


async def _fetch_langfuse_prompt() -> str | None:
"""Fetch the greeting instructions from Langfuse.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Tests for the greeting generation's prompt assembly.

The greeting promises the user specific automations, so what the model is
told this platform can connect to is part of the contract β€” a greeting
built without the registry offers integrations we do not have.
"""

import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest
from pytest_mock import MockerFixture

from backend.api.features.onboarding_dump import intro

TRANSCRIPT = "I run a bakery and want the weekly order emails handled."

GENERATION = {
"greeting": "You mentioned the weekly order emails.",
"prompts": [
{"title": f"Automation number {i}", "prompt": "Do it.", "icon": "sparkle"}
for i in range(5)
],
}


@pytest.fixture
def client(mocker: MockerFixture) -> MagicMock:
completion = SimpleNamespace(
choices=[
SimpleNamespace(message=SimpleNamespace(content=json.dumps(GENERATION)))
]
)
fake = MagicMock()
fake.chat.completions.create = AsyncMock(return_value=completion)
mocker.patch(
"backend.api.features.onboarding_dump.intro.get_openai_client",
return_value=fake,
)
mocker.patch(
"backend.api.features.onboarding_dump.intro._fetch_langfuse_prompt",
new=AsyncMock(return_value=None),
)
return fake


@pytest.mark.asyncio
async def test_greeting_prompt_carries_the_provider_registry(
client: MagicMock, mocker: MockerFixture
):
mocker.patch(
"backend.api.features.onboarding_dump.intro.known_providers",
return_value={"slack": "Team chat", "github": None},
)

greeting, prompts = await intro.generate_intro(TRANSCRIPT)

content = client.chat.completions.create.await_args.kwargs["messages"][0]["content"]
assert "- slack: Team chat" in content
assert "- github" in content
# Registry first, then the instructions, then the transcript they end
# with β€” the static half of the message stays a stable prefix.
assert content.index("- slack") < content.index(TRANSCRIPT)
assert greeting == GENERATION["greeting"]
assert len(prompts) == 5


@pytest.mark.asyncio
async def test_greeting_prompt_survives_an_empty_registry(
client: MagicMock, mocker: MockerFixture
):
mocker.patch(
"backend.api.features.onboarding_dump.intro.known_providers",
return_value={},
)

greeting, _ = await intro.generate_intro(TRANSCRIPT)

content = client.chat.completions.create.await_args.kwargs["messages"][0]["content"]
assert content.startswith(intro._LOCAL_PROMPT[:40])
assert greeting == GENERATION["greeting"]
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ class IntroCardResponse(BaseModel):
greeting: str
prompts: list[SuggestedPrompt] = []
greeting_done: bool = False
greeting_pending: bool = False
"""True while the greeting is still being written. An empty greeting
used to carry this meaning by implication; saying it outright is what
Comment thread
Abhi1992002 marked this conversation as resolved.
Outdated
lets the client tell "still coming" apart from "there isn't one"."""
transcript: str | None = None
"""The full transcript of the recorded dump, so the greeting page can
offer a copy button. Only present on Path A while the greeting is
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""The live integration registry, as prompt context.

Both LLM jobs need to know what this platform can actually connect to:
the recommender picks from the list, and the greeting needs it so the
automations it proposes are ones we can really run. Shared here so the
two can never drift onto different views of the registry.
"""

import logging

from backend.api.features.integrations.models import (
get_all_provider_names,
get_provider_description,
)
from backend.blocks import load_all_blocks

logger = logging.getLogger(__name__)


Comment thread
Abhi1992002 marked this conversation as resolved.
def known_providers() -> dict[str, str | None]:
"""The live provider registry as ``{id: description}``.

Mirrors the ``/providers`` endpoint: block modules must be imported
before AutoRegistry knows about SDK-registered providers.
Comment thread
Abhi1992002 marked this conversation as resolved.
Comment thread
Abhi1992002 marked this conversation as resolved.
Comment thread
Abhi1992002 marked this conversation as resolved.
"""
Comment thread
Abhi1992002 marked this conversation as resolved.
Comment thread
Abhi1992002 marked this conversation as resolved.
try:
load_all_blocks()
except Exception as e: # static providers still work
Comment thread
Abhi1992002 marked this conversation as resolved.
logger.warning("Brain dump: block load failed: %s", e)
return {name: get_provider_description(name) for name in get_all_provider_names()}


def provider_lines(providers: dict[str, str | None]) -> str:
"""``providers`` as one ``- id: description`` line each."""
return "\n".join(
f"- {name}: {description}" if description else f"- {name}"
for name, description in providers.items()
)
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@
import logging
import os

from backend.api.features.integrations.models import (
get_all_provider_names,
get_provider_description,
)
from backend.api.features.onboarding_dump.models import RecommendedProvider
from backend.api.features.onboarding_dump.parsing import parse_response_json
from backend.api.features.onboarding_dump.providers import (
known_providers,
provider_lines,
)
from backend.util.clients import get_openai_client

logger = logging.getLogger(__name__)

_MODEL = os.environ.get("BRAIN_DUMP_RECOMMEND_MODEL", "anthropic/claude-sonnet-5")
# Picking six ids off a list is a matching task, not a writing one, and
# the onboarding loading screen holds the user until it answers β€” so this
# runs on the fast model rather than the one that writes the greeting.
_MODEL = os.environ.get("BRAIN_DUMP_RECOMMEND_MODEL", "anthropic/claude-haiku-4-5")
_TIMEOUT_SECONDS = 30

MAX_RECOMMENDATIONS = 6
Expand Down Expand Up @@ -66,13 +69,10 @@ async def generate_recommendations(transcript: str) -> list[RecommendedProvider]
logger.warning("Brain dump recommendations: no LLM client configured")
return []

known = _known_providers()
known = known_providers()
prompt = _PROMPT.format(
max_recommendations=MAX_RECOMMENDATIONS,
providers="\n".join(
f"- {name}: {description}" if description else f"- {name}"
for name, description in known.items()
),
providers=provider_lines(known),
transcript=text,
)
try:
Expand All @@ -93,21 +93,6 @@ async def generate_recommendations(transcript: str) -> list[RecommendedProvider]
return _parse_recommendations(data, set(known))


def _known_providers() -> dict[str, str | None]:
"""The live provider registry as ``{id: description}``.

Mirrors the ``/providers`` endpoint: block modules must be imported
before AutoRegistry knows about SDK-registered providers.
"""
try:
from backend.blocks import load_all_blocks

load_all_blocks()
except Exception as e: # static providers still work
logger.warning("Brain dump recommendations: block load failed: %s", e)
return {name: get_provider_description(name) for name in get_all_provider_names()}


def _parse_recommendations(data: object, known: set[str]) -> list[RecommendedProvider]:
items = data.get("providers") if isinstance(data, dict) else None
if not isinstance(items, list):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,22 @@ def dumps(mocker: MockerFixture) -> DumpStore:
return store


@pytest.fixture(autouse=True)
def session_count(mocker: MockerFixture) -> AsyncMock:
"""A brand-new user by default; the greeting is only for those.

The intro route asks how many chat sessions the user has, which is a
real query β€” left unstubbed these route tests reach the database on
the TestClient's own event loop, and the connection it leaves behind
outlives that loop and breaks the next test that queries for real.
"""
mock = AsyncMock(return_value=0)
mocker.patch(
"backend.api.features.onboarding_dump.service.get_user_session_count", new=mock
)
return mock


@pytest.fixture(autouse=True)
def storage_mocks(mocker: MockerFixture) -> dict[str, AsyncMock]:
module = "backend.api.features.onboarding_dump.storage"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
RecommendedProvidersResponse,
SuggestedPrompt,
)
from backend.copilot.db import get_user_session_count
from backend.data.onboarding import format_brain_dump_for_extraction
from backend.data.tally import extract_business_understanding
from backend.data.understanding import (
Expand Down Expand Up @@ -430,7 +431,14 @@ async def _extract_and_complete(
):
return _superseded_response(user_id, input_mode)

extracted = await _extract_understanding(user_id, transcript)
# The greeting reads the transcript, not the extraction, so running it
Comment thread
Abhi1992002 marked this conversation as resolved.
Outdated
# behind extraction only added that call's latency to the loading
# screen. Both degrade internally rather than raising, so a gather
# here cannot lose one to the other's failure.
Comment thread
Abhi1992002 marked this conversation as resolved.
Outdated
extracted, (greeting, suggested_prompts) = await asyncio.gather(
_extract_understanding(user_id, transcript),
intro.generate_intro(transcript),
)
extracted.additional_notes = _append_note(
extracted.additional_notes, transcript, input_mode
)
Expand All @@ -441,10 +449,6 @@ async def _extract_and_complete(
return _superseded_response(user_id, input_mode)
await upsert_business_understanding(user_id, extracted)

# Generated here, while the onboarding loading screen is still up, so
# the copilot home can render its greeting without waiting.
greeting, suggested_prompts = await intro.generate_intro(transcript)

await db.update_dump(
user_id,
recording_id,
Expand Down Expand Up @@ -540,6 +544,9 @@ async def get_intro_card(user_id: str) -> IntroCardResponse:
# greeting must never reappear once the first message is sent.
return IntroCardResponse(path="A", greeting="", greeting_done=True)

if await _has_chatted(user_id):
Comment thread
Abhi1992002 marked this conversation as resolved.
Outdated
return IntroCardResponse(path="A", greeting="", greeting_done=True)

if (
dump is None
or dump.inputMode == BrainDumpInputMode.skipped
Expand All @@ -562,10 +569,10 @@ async def get_intro_card(user_id: str) -> IntroCardResponse:
BrainDumpStatus.failed,
):
# The background half of the pipeline is still writing the
# greeting. An empty Path A response tells the client to keep
# polling β€” serving the generic fallback to a brand-new user
# would waste the personalised one that is seconds away.
return IntroCardResponse(path="A", greeting="")
# greeting. The client holds its loader and keeps polling β€”
# serving the generic fallback to a brand-new user would waste
# the personalised one that is seconds away.
return IntroCardResponse(path="A", greeting="", greeting_pending=True)
if not greeting:
# Completed before the greeting column existed, or generation
# terminally failed after the transcript landed.
Expand All @@ -578,6 +585,35 @@ async def get_intro_card(user_id: str) -> IntroCardResponse:
)


async def _has_chatted(user_id: str) -> bool:
Comment thread
Abhi1992002 marked this conversation as resolved.
Outdated
"""Whether this user already has a chat session, greeting retired if so.

``greetingSeen`` only covers users whose first message went through
the copilot home while the flag was on β€” an existing account, a
session started from an expert page or a shared link, or a first send
whose completion call failed all leave it false, and the greeting
would then greet a user mid-conversation as brand new. One session is
enough to say they are past this.
Comment thread
Abhi1992002 marked this conversation as resolved.
Outdated

A failure here must not cost a genuinely new user their greeting, so
it answers "no" and the rest of the checks decide.
"""
try:
if await get_user_session_count(user_id) == 0:
return False
except Exception as e:
logger.warning("Brain dump greeting: session count failed: %s", e)
return False
try:
# Bookkeeping behind a verdict that already stands: a failed write
# costs another count on the next load, not the answer β€” and must
# not 500 the card it was about to retire.
await db.mark_greeting_seen(user_id)
except Exception as e:
logger.warning("Brain dump greeting: seen flag not recorded: %s", e)
return True


def _stored_prompts(raw: object) -> list[SuggestedPrompt]:
"""Rehydrate the stored prompt list, dropping anything malformed.

Expand Down
Loading
Loading