Outreach AI Draft Composition
prospecting/outreach-ai-draft-composition
| Kind | backend |
|---|---|
| Domain | prospecting |
| Maturity | broken |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, aiohttp-3.13, httpx-0.28, beautifulsoup4-4.12, lxml-5.3, pydantic-2.12 |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/alembic-migrations, _shared/test-layout |
| Frontends | prospecting/ui/outreach-campaign-workspace |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
Given one OutreachEmail row that already has a scraped lead attached, this service turns the lead's scraped site data (plus the campaign's optional free-text template_prompt) into a German subject line and an HTML body, wraps that body in Alchemy's fixed outreach email shell, and leaves the row in draft status so a human approves it before anything is sent (compose_email, composition_service.py 47 to 114). The invariant it is supposed to hold is that a human always reviews AI copy before send, compose_email only ever writes OutreachEmailStatus.draft on success (102) or OutreachEmailStatus.scrape_failed on any exception (110), it never sets approved, queued or sent itself, those are separate, later steps in outreach_service.py (approve_email 265, send_email 297). At the measured commit this invariant cannot actually be exercised: the call this function makes into the shared Mistral client raises before a draft is ever produced (Gotchas), so today every compose_email call lands in scrape_failed, not draft.
When to use
Trigger phrases: "compose the outreach email", "AI draft", "why is the email stuck in scrape_failed after scraping succeeded", "BETREFF format", "outreach email template", "cold outreach copy". Read this before touching compose_email, compose_batch, _call_mistral, _wrap_in_template, _html_to_text, or the SYSTEM_PROMPT constant (composition_service.py 18 to 41).
Not this recipe: scraping the lead's website into lead.scraped_data/lead.scrape_status (scraper_service.py, called one step earlier by outreach_service.scrape_and_generate 195 to 213, a separate recipe); campaign and lead CRUD, CSV/XLSX import (outreach_service.py 35 to 188, outreach_router.py 26 to 77); sending the composed draft over Gmail, click/open tracking, reply detection (gmail_service.py, tracking_service.py, outreach_service.send_email/process_queue/detect_replies 297 to 438, separate recipes); the shared Mistral/Infomaniak transport itself, this recipe only calls get_mistral_client() and is a consumer of it, not its owner (services/mistral/client.py, out of scope beyond the one call site it breaks on).
Stack assumptions
At 62ba4fc4: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43), aiohttp==3.13.0 (the shared Mistral client's transport), httpx==0.28.1 (the neighbouring scraper's transport, not this file's), beautifulsoup4==4.12.3 + lxml==5.3.0 (_html_to_text, 195 to 199), pydantic==2.12.0 (MistralMessage/MistralRequest in the shared client), psycopg2-binary==2.9.10, alembic==1.16.5 (backend/requirements.txt 3, 6, 14, 15, 32, 50, 76, 80, 109). Foundations assumed: S4 (fastapi-project-layout) for the app and router mount; S5 (auth-dependency-pattern) for AdminUser; S6 (workspace-scoping) for WorkspaceID and the campaign-scoped queries this service's caller relies on; S8 (alembic-migrations) for the two chained migrations under Data model.
No dedicated environment variable gates this file directly. It inherits the shared Mistral/Infomaniak client's configuration indirectly through get_mistral_client() (client.py 716 to 730): PRIMA_LLM_PROVIDER (core/config.py 689, default "infomaniak", infomaniak | mistral_direct, any other value raises ValueError in MistralClient.__init__, 246 to 265), INFOMANIAK_API_TOKEN/MOONSHOT_API_KEY (684 to 685), INFOMANIAK_AI_BASE_URL/INFOMANIAK_AI_MODEL (686 to 687), and, only under PRIMA_LLM_PROVIDER=mistral_direct, MISTRAL_API_KEY (675). get_mistral_client() also reads MISTRAL_TEMPERATURE/MISTRAL_MAX_TOKENS off settings via getattr(..., 0.7)/getattr(..., 4096) (client.py 726 to 727); neither key exists on the Settings model in core/config.py (not found by grep -n "MISTRAL_TEMPERATURE\|MISTRAL_MAX_TOKENS" backend/core/config.py), so the getattr default is what actually applies, always, regardless of environment. Fail behaviour: compose_email catches every exception from the try block (67 to 114) and downgrades the row to scrape_failed with error_message = f"Composition failed: {str(e)}" (109 to 113), it never raises past its own boundary; compose_batch (116 to 125) has no exception handling of its own beyond calling compose_email per row, one row's caught failure cannot stop the batch.
Data model
No table in this recipe's scope is created or altered by composition_service.py itself; it reads and writes columns on two tables owned by outreach_model.py and migrated in migrations/versions/20260225_add_outreach_tables.py / 20260408b_add_workspace_id_to_outreach.py. Columns this recipe actually touches are marked; the rest of each table belongs to the neighbouring recipes under When to use.
outreach_campaigns (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -- added 20260408b, backfilled to one hardcoded workspace prefix, then set NOT NULL
name varchar NOT NULL,
status varchar NOT NULL DEFAULT 'draft', -- CampaignStatus: draft|active|paused|completed, plain string column, no CHECK
template_prompt text, -- ★ read by compose_email (90 to 91), the only per-campaign input into the prompt
sender_name varchar DEFAULT 'Miriam',
sender_email varchar,
-- send_interval_minutes, daily_send_limit, gmail_integration_id, total_* counters: owned by the send/stats recipes, not read here
created_at timestamptz, updated_at timestamptz
);
ix_outreach_campaigns_status, ix_outreach_campaigns_workspace_id (20260408b)
outreach_emails (
id uuid PRIMARY KEY,
campaign_id uuid NOT NULL REFERENCES outreach_campaigns(id),
lead_id uuid NOT NULL REFERENCES leads(id), -- ★ compose_email's only lookup key into leads (57)
subject varchar, -- ★ written by compose_email (99)
body_html text, -- ★ written by compose_email (100), template-wrapped HTML
body_text text, -- ★ written by compose_email (101), BeautifulSoup-stripped plain text
status varchar NOT NULL DEFAULT 'pending_scrape', -- ★ OutreachEmailStatus, this recipe only ever writes composing|draft|scrape_failed
tracking_id uuid UNIQUE NOT NULL, -- owned by tracking_service, untouched here
error_message text, -- ★ written on the except branch (111)
composed_at timestamptz, -- ★ written on success (103)
-- gmail_message_id, gmail_thread_id, open/click counters, the other *_at columns: owned by send/tracking recipes
created_at timestamptz, updated_at timestamptz
);
leads (in crm_model.py, not migrated by this recipe's files, cited for context): scraped_data JSONB and scrape_status varchar were added to the pre-existing leads table by 20260225_add_outreach_tables.py's _column_exists guard (36 to 41); compose_email reads lead.scraped_data (dict, .get("company_name"|"description"|"services"|"tagline"|"industry"), 69 to 82) and the lead's own first_name/last_name/company_name/website columns (73, 83 to 86), never writes to leads.
What is NOT stored anywhere in this recipe: the AI-generated draft body is stored in the clear in outreach_emails.body_html/body_text (a human reviews it before send, this is deliberate, not a gap); no separate audit table of what prompt produced what draft, the only record is the row itself, overwritten on every compose_email retry (a second call on the same row replaces subject/body_html/body_text in place, there is no version history).
API surface
No route in outreach_router.py calls compose_email/compose_batch/_call_mistral directly. The one route that reaches this file goes through outreach_service.scrape_and_generate, which composes as its second half:
| Route | Auth | Tenancy | Codes | Notes |
|---|---|---|---|---|
POST /api/crm/outreach/campaigns/{campaign_id}/scrape-and-generate (outreach_router.py 82 to 85, mounted at /api/crm by app_fastapi.py 878) | AdminUser (api/dependencies.py 568, Depends(get_current_admin_user)) | WorkspaceID (779) passed to outreach_service.get_campaign, which 404s if the campaign's workspace_id does not match (outreach_service.py 43 to 54) | 200 {"scraped": int, "scrape_failed": int, "composed": int, "compose_failed": int}; 404 if the campaign is not in the caller's workspace | Calls composition_service.compose_batch(db, pending_compose, campaign) (outreach_service.py 232) only for emails already in pending_compose after the scrape half of the same call (189 to 240); compose_batch's return dict keys are success/failed, remapped to composed/compose_failed here (236 to 240) |
The two Python entrypoints this route calls into, not routes themselves:
| Function | Owns its transaction | Never raises | Returns |
|---|---|---|---|
compose_email(db, email, campaign=None) (47 to 114) | yes, one db.flush() (65) then one db.commit() per outcome (61, 104, 112) | yes, its own try/except is the boundary (67 to 114) | the mutated OutreachEmail, status is draft or scrape_failed |
compose_batch(db, emails, campaign=None) (116 to 125) | delegates to compose_email per row | yes, inherits compose_email's guarantee | {"success": int, "failed": int} |
Application logic
compose_email (47 to 114), in source order:
- Look up the
Leadbyemail.lead_id(57); no lead ⇒status = scrape_failed,error_message = "Lead not found", commit, return early (58 to 62), never touches Mistral. - Mark
status = composinganddb.flush()(64 to 65, visible to a concurrent reader inside the same transaction, not yet committed). - Build
context_partsfromlead.scraped_data(a JSONB dict,.get(...)on six keys, each only appended if truthy) and from the lead row's ownfirst_name/last_name/company_name/website(69 to 86); join intouser_promptwith a fixed German lead-in line (88); if the campaign carries a non-emptytemplate_prompt, append it verbatim under a"Zusätzliche Anweisungen:"heading (90 to 91), this is the only per-campaign customization point. - Call
self._call_mistral(user_prompt)(94), expecting a(subject, body_html)tuple. - Wrap the returned body in the fixed Alchemy template via
self._wrap_in_template(body_html)(97, see below). - Write
subject,body_html,body_text(the template-wrapped HTML flattened throughself._html_to_text, 99 to 101), setstatus = draft, stampcomposed_at = datetime.now(ZoneInfo("UTC"))(103), commit (104). - Any exception anywhere in steps 3 to 6 is caught by the single
except Exception as e(109):status = scrape_failed,error_message = f"Composition failed: {str(e)}", commit, log, return the row (109 to 114). This is the path every call currently takes, see Gotchas.
_call_mistral (127 to 167): imports get_mistral_client, MistralMessage, MistralRequest from the shared client module (129); builds a MistralRequest(model="mistral-small-latest", messages=[system, user], temperature=0.7, max_tokens=1000) (132 to 140), the model string is Mistral's own direct-API naming, not an Infomaniak model id (see Gotchas); gets or creates an event loop to run the client call synchronously (142 to 150, asyncio.get_event_loop(), falling back to asyncio.new_event_loop() on RuntimeError or a closed loop); calls client.chat(request) inside loop.run_until_complete(...) (151); reads response.content if present, else str(response) (153); parses a "BETREFF: ...\n---\n..." format out of the content (159 to 166), a fixed fallback subject line (156, German, names Alchemy Zürich and branding) is used whenever the model's reply doesn't contain the literal string "BETREFF:".
_wrap_in_template (169 to 193): a pure f-string, no external call, no conditional, always returns the same fixed shell (a hardcoded street address, sender mailbox and marketing site link, all Alchemy Zürich's own) around whatever body_html it is given.
_html_to_text (195 to 199): BeautifulSoup(html, "lxml").get_text(separator="\n", strip=True), no exception handling of its own, an exception here is caught by compose_email's outer try/except.
compose_batch (116 to 125): iterates emails in the order given by its caller (an ordinary Python list, outreach_service.py 226 to 230 populates it from an unordered db.query(...).all(), no explicit ORDER BY), calls compose_email per row, tallies success/failed by reading the row's own status back (== OutreachEmailStatus.draft) rather than trusting a return value, so any future change to compose_email's return contract cannot silently break this count.
Invariants
compose_emailnever leaves a row incomposing: every path out of the function sets eitherdraft,scrape_failed(no lead), orscrape_failed(the except branch) before returning (58 to 62, 102 to 107, 109 to 114). No test asserts this at the measured commit (Tests).- A draft is only ever produced by the success path; there is no code path that sets
status = draftwithout also having setsubject,body_html,body_textandcomposed_atin the same block (99 to 104), they are five consecutive statements before the one commit. compose_batch's tally reads the persistedstatuscolumn back off each row rather than a return code (121), so the two can never disagree by construction.- The prompt sent to the model never includes
email.subject/body_html/body_textfrom a prior attempt, a retriedcompose_emailcall rebuildsuser_promptfromlead/campaignalone (69 to 91) and overwrites whatever the row held before; there is no accumulation of prior drafts into the next attempt. - Not actually holding at the measured commit: "a
compose_emailcall on a fully scraped lead produces adraft" is false today, see Gotchas, the code path that would make it true has never run outside a hand test (0% coverage of any method body, Tests).
Frontend
The module that would render this, by id:
prospecting/ui/outreach-campaign-workspace the admin-facing campaign screen where a human
triggers scrape-and-generate, then reviews,
edits and approves the resulting drafts
(list_emails/update_email/approve_email in
outreach_service.py, 242 to 296, a neighbouring
recipe); at 62ba4fc4 nothing in this repo's
frontend has been located that calls
/outreach/campaigns/{id}/scrape-and-generate,
so this module's existence is inferred from the
route existing and being admin-auth-gated, not
confirmed by a found call site
Minimum admin and debug surface (no dedicated status route exists at this commit): SELECT id, status, error_message, composed_at FROM outreach_emails WHERE campaign_id = :id AND status IN ('composing','scrape_failed') ORDER BY updated_at DESC (a stuck or failed composition, the error_message column is the only place the caught exception's str(e) survives); the log lines COMPOSE: Generated email for lead_id=%s - subject: %s (106) and COMPOSE: Failed for lead_id=%s: %s (113), the second is currently the common case (Gotchas).
Security & tenancy
Auth dependency: compose_email/compose_batch carry no auth of their own, they are plain service functions; the only auth on the path to them is the route's AdminUser (outreach_router.py 83, api/dependencies.py 568). Workspace derivation: neither compose_email nor compose_batch takes or checks a workspace_id; tenancy is enforced one call frame up, by outreach_service.get_campaign requiring OutreachCampaign.workspace_id == workspace_id before any email in that campaign is queried (outreach_service.py 43 to 54, 195), and by outreach_service.scrape_and_generate only ever passing this recipe emails already filtered campaign_id == campaign_id (218 to 223). The Lead lookup inside compose_email itself (57) filters only on Lead.id, not on workspace, so this function alone is not tenant-safe, it is safe only because every caller found in this repo has already scoped the campaign (and therefore the email, and therefore the lead) to a workspace before reaching it; a new caller that passes an OutreachEmail from a different workspace's campaign would compose against it without complaint.
Fail direction: open, not closed, on the one guard this file does have. A Lead that doesn't exist is caught explicitly (58 to 62); everything else, including an auth/config failure inside the shared Mistral client, is caught by the same blanket except Exception as a normal composition failure (109) and reported back only as a generic "Composition failed: ..." string on the row, there is no distinct signal for "the AI backend is unreachable" versus "the model returned something unparseable" versus, at the measured commit, "the client we called doesn't have this method".
Secrets and PII at rest: no API key or bearer token is read, stored or logged by this file, the shared Mistral client owns and redacts those (out of scope). lead.scraped_data (a JSONB blob scraped from the lead's own public website) and the lead's name/company/website are sent to the LLM backend as the user prompt (69 to 91) and end up verbatim inside outreach_emails.body_html once composed, this is the intended personalization, not a leak, but it means a draft row carries third-party PII by design and is not itself redacted anywhere in this file. Rate limits: none in this file; the shared Mistral client has its own token-bucket RateLimiter (client.py 125 to 190), out of this recipe's scope. Attacker-controlled input: none reaches this file directly, email/campaign are trusted SQLAlchemy rows already loaded by an admin-authenticated, workspace-scoped caller; the closest thing to attacker-controlled data is whatever lead.scraped_data picked up from a scraped third-party website, which flows unsanitized into the LLM prompt (a prompt-injection surface on the scraper's side, not fixed or worsened by this file).
Tests
No test file for this service was found. grep -rl "composition_service\|CompositionService\|OutreachEmail\|OutreachCampaign" backend/tests/ and a directory search for *outreach* / *composition* under backend/tests/ both return nothing beyond two unrelated hits (backend/tests/test_booking_lead_bridge.py:166,180 and backend/tests/integration/test_workspace_isolation.py:113, both just constructing a Lead(source=LeadSource.cold_outreach) for CRM tests, neither imports or calls anything in composition_service.py). This is a genuine gap, not an oversight in this recipe: there is no known-answer vector, no hermetic stub of _call_mistral, and no live test for this file at the measured commit.
The repo does carry a stale coverage report at backend/htmlcov/z_e9a6f84b985c17cb_composition_service_py.html, 18% line coverage. Read line by line, the "run" lines are exactly the module-level statements that execute on import (imports, the SYSTEM_PROMPT string, the class/def statement lines themselves, 5 to 52, 116, 127, 169, 195, 202) and nothing else; every line inside every method body (compose_email 57 to 114 except its own def, compose_batch 118 to 125, _call_mistral 129 to 165, _wrap_in_template 171 to 193, _html_to_text 197 to 199) is marked "mis". In plain terms: 18% coverage here means the file was imported by some other test's fixture chain, not that any of its logic ran. Whatever generated that report predates this recipe and is not reproducible from a command found in this repo; there is no pytest.ini marker or conftest.py fixture under backend/tests/ scoped to outreach the way backend/tests/prima_credits/conftest.py scopes the billing-credits tests.
What a new implementation needs and this branch lacks entirely: a hermetic test that monkeypatches get_mistral_client/_call_mistral and asserts compose_email reaches status == draft (the success path is currently unexercised end to end); a test asserting the "no lead" short-circuit (58 to 62); a test asserting compose_batch's tally matches the persisted statuses when one row in the batch fails; and, given the Gotcha below, a test that would have caught client.chat not existing on MistralClient before this shipped.
Deploy & ops
Config keys: none owned by this file; it inherits the shared Mistral client's keys listed under Stack assumptions. Migrations: outreach_001 (20260225_add_outreach_tables.py, down-revision metal_tiers_001) creates outreach_campaigns/outreach_emails/outreach_links and adds scraped_data/scrape_status/scraped_at to leads, guarded by _column_exists/_table_exists so it is safe to run against a partially-applied state (19 to 31); outreach_ws_001 (20260408b_add_workspace_id_to_outreach.py, down-revision leads_ws_001) adds outreach_campaigns.workspace_id, backfills every existing row to one hardcoded workspace id, matched by a literal id-prefix string in the migration's own SQL (24 to 30), then sets it NOT NULL (31); a fresh database with no pre-existing campaigns skips the backfill's WHERE workspace_id IS NULL update harmlessly. No scheduler job in this recipe's scope; composition only runs synchronously inside the scrape-and-generate request.
Argus-style checks: none found targeting this file specifically. Rollback: both migrations have explicit upgrade()s; only 20260408b defines a downgrade() (35 to 37, drops the index and the column); 20260225 has no downgrade() in the file as read, a rollback of that migration is not scripted.
Gotchas
- 2026-09-12 (measured at 62ba4fc4,
composition_service.py151 vsservices/mistral/client.py, confirmed by AST-walking theMistralClientclass body):_call_mistralcallsclient.chat(request), andMistralClientdefines no method namedchat. Its public chat surface ischat_completion(421, takesmessages: List[Dict[str,str]]plus kwargs, not aMistralRequestobject),stream_chat_completion(490),raw_chat_completion(589), andgenerate_with_context(623). Callingcompose_emailtoday raisesAttributeError: 'MistralClient' object has no attribute 'chat'inside_call_mistral, caught bycompose_email's ownexcept Exception(109), and the row lands inscrape_failedwitherror_message = "Composition failed: 'MistralClient' object has no attribute 'chat'". Even if.chatexisted,chat_completionreturns the raw parsed JSON body as adict(result = await response.json(),client.py470), which has no.contentattribute either, so line 153'shasattr(response, "content")check would fall through tostr(response), a Python dict repr, which would never contain the literal"BETREFF:"the parser looks for (159). Both problems point the same direction: this call site was written against the direct Mistral chat-completion SDK shape (a.chat.completions.create()-style client with a response object carrying.content/.choices[0].message.content), not against this repo's ownMistralClient, and has never been exercised end to end (Tests). - 2026-09-12 (measured at 62ba4fc4,
composition_service.py133 vscore/config.py937):_call_mistralhardcodesmodel="mistral-small-latest", which is a direct-Mistral-API model id (MistralClient.MODELS,client.py209 to 213). The default transport as of this commit isPRIMA_LLM_PROVIDER=infomaniak(core/config.py689), whose base URL is Infomaniak AI Services, notapi.mistral.ai(services/mistral/client.pymodule docstring, 1 to 17); the siblingSPARK_MISTRAL_MODELsetting (core/config.py937) is explicitly documented as needing "an Infomaniak model id by default, not Mistral's own 'mistral-small-latest'" for the same client class. ThisMistralRequest.modelvalue is not read bychat_completionanyway (it always sendsself.model, resolved frominfomaniak_chat_model(),client.py454), so today it is inert, but it would be a live mismatch the moment_call_mistralis rewritten to callchat_completioncorrectly and starts honoring a caller-supplied model. - 2026-09-12 (measured at 62ba4fc4):
_call_mistral's event-loop handling (asyncio.get_event_loop(), 144) calls a function deprecated by CPython when there is no running loop; inside a synchronous FastAPI request handler (this file's only real caller path) there is no running loop, so this line's deprecation warning fires on every real invocation, not just an edge case. - 2026-09-12 (measured at 62ba4fc4): the fallback subject line (156) and the entire
SYSTEM_PROMPT(18 to 41) are Alchemy Zürich-specific (agency name, services list, signature "Miriam"), hardcoded, not templated offcampaign.sender_nameor any workspace field even thoughOutreachCampaign.sender_nameexists (outreach_model.py60) and is passed nowhere into the prompt; a second tenant using this recipe as-is would get Alchemy's own pitch in every draft.
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/outreach/composition_service.py(202 lines, whole file in scope): module docstring (1 to 4);SYSTEM_PROMPT(18 to 41);CompositionService.compose_email(47 to 114);compose_batch(116 to 125);_call_mistral(127 to 167);_wrap_in_template(169 to 193);_html_to_text(195 to 199); module singletoncomposition_service(202).alchemy_labs/backend/models/outreach_model.py(146 lines, whole file in scope):CampaignStatus/OutreachEmailStatusenums (17 to 39);OutreachCampaign(44 to 77);OutreachEmail(80 to 124);OutreachLink(127 to 146).alchemy_labs/backend/services/outreach/outreach_service.py:get_campaign(43 to 54, the workspace guard this file relies on);scrape_and_generate(189 to 240, this file's only caller).alchemy_labs/backend/api/routers/outreach_router.py(168 lines, whole file in scope):scrape_and_generateroute (82 to 85).alchemy_labs/backend/schemas/outreach_schemas.py(130 lines, whole file in scope, cited for the request/response shapes around this file, none of which this file itself defines).alchemy_labs/backend/services/mistral/client.py: module docstring on the Infomaniak-by-default transport (1 to 17);MistralMessage/MistralRequest(62 to 76);MistralClient.__init__and provider resolution (214 to 295);chat_completion(421 to 483);get_mistral_client(716 to 730). Cited for context and for the Gotcha above; owned by a different recipe.alchemy_labs/backend/migrations/versions/20260225_add_outreach_tables.py,20260408b_add_workspace_id_to_outreach.py.alchemy_labs/backend/api/dependencies.py:AdminUser(568),DatabaseSession(572),WorkspaceID(779).- Tests: none found for this file (see Tests).
Deviations of the reference from an ideal implementation, all dated 2026-09-12: _call_mistral calls a method the shared client does not expose and has apparently never run successfully outside, at most, a hand test (Gotchas); the hardcoded model id, system prompt and fallback subject are single-tenant (Alchemy-only) and not parameterized off the campaign or workspace despite sender_name existing on the campaign row for exactly that purpose; no test file exists for any function in this recipe.
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc4 unless noted. Every check below is either a grep that reproduces exactly, or a statement of what could not be run and why.
grep -n "def _call_mistral" backend/services/outreach/composition_service.pyprints127;grep -n "client.chat(request)" backend/services/outreach/composition_service.pyprints151.grep -n "^ async def \|^ def " backend/services/mistral/client.py | grep -c "def chat("prints0, confirming no method literally namedchatexists onMistralClient(its chat-shaped methods arechat_completion,stream_chat_completion,raw_chat_completion,generate_with_context, all listed by the same grep without thechat(filter).python3 -c "import ast; t=ast.parse(open('backend/services/outreach/composition_service.py').read()); print([n.name for n in ast.walk(t) if isinstance(n, ast.FunctionDef)])"prints['compose_email', 'compose_batch', '_call_mistral', '_wrap_in_template', '_html_to_text'], the complete method surface this recipe documents, nothing more.grep -rl "composition_service\|CompositionService" backend/tests/returns no output (no match), confirming the Tests section's claim that no test file imports this service.- A live run (
compose_emailagainst a realOutreachEmail/Leadpair withPRIMA_LLM_PROVIDERresolvable) was not attempted as part of authoring this recipe: doing so would send a scraped lead's data to a live LLM backend and was out of scope for a read-only, no-network authoring pass; theAttributeErrorclaim in Gotchas is established statically (checks 1 to 3 above), not by executing the function. grep -c "^ async def \|^ def " backend/services/mistral/client.pyprints18(the full method count onCostTracker,RateLimiterandMistralClientcombined, listed in check 2's grep), a stable count to re-check this recipe's citations against on a future commit.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.