Prima Get an API key

Module kit / Prospecting

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
  • alchemy_labs/backend/services/outreach/composition_service.py
  • alchemy_labs/backend/models/outreach_model.py
  • alchemy_labs/backend/services/outreach/outreach_service.py
  • alchemy_labs/backend/api/routers/outreach_router.py
  • alchemy_labs/backend/schemas/outreach_schemas.py
  • alchemy_labs/backend/services/mistral/client.py
  • alchemy_labs/backend/migrations/versions/20260225_add_outreach_tables.py
  • alchemy_labs/backend/migrations/versions/20260408b_add_workspace_id_to_outreach.py
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:

RouteAuthTenancyCodesNotes
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 workspaceCalls 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:

FunctionOwns its transactionNever raisesReturns
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 rowyes, inherits compose_email's guarantee{"success": int, "failed": int}

Application logic

compose_email (47 to 114), in source order:

  1. Look up the Lead by email.lead_id (57); no lead ⇒ status = scrape_failed, error_message = "Lead not found", commit, return early (58 to 62), never touches Mistral.
  2. Mark status = composing and db.flush() (64 to 65, visible to a concurrent reader inside the same transaction, not yet committed).
  3. Build context_parts from lead.scraped_data (a JSONB dict, .get(...) on six keys, each only appended if truthy) and from the lead row's own first_name/last_name/company_name/website (69 to 86); join into user_prompt with a fixed German lead-in line (88); if the campaign carries a non-empty template_prompt, append it verbatim under a "Zusätzliche Anweisungen:" heading (90 to 91), this is the only per-campaign customization point.
  4. Call self._call_mistral(user_prompt) (94), expecting a (subject, body_html) tuple.
  5. Wrap the returned body in the fixed Alchemy template via self._wrap_in_template(body_html) (97, see below).
  6. Write subject, body_html, body_text (the template-wrapped HTML flattened through self._html_to_text, 99 to 101), set status = draft, stamp composed_at = datetime.now(ZoneInfo("UTC")) (103), commit (104).
  7. 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_email never leaves a row in composing: every path out of the function sets either draft, scrape_failed (no lead), or scrape_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 = draft without also having set subject, body_html, body_text and composed_at in the same block (99 to 104), they are five consecutive statements before the one commit.
  • compose_batch's tally reads the persisted status column 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_text from a prior attempt, a retried compose_email call rebuilds user_prompt from lead/campaign alone (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_email call on a fully scraped lead produces a draft" 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.py 151 vs services/mistral/client.py, confirmed by AST-walking the MistralClient class body): _call_mistral calls client.chat(request), and MistralClient defines no method named chat. Its public chat surface is chat_completion (421, takes messages: List[Dict[str,str]] plus kwargs, not a MistralRequest object), stream_chat_completion (490), raw_chat_completion (589), and generate_with_context (623). Calling compose_email today raises AttributeError: 'MistralClient' object has no attribute 'chat' inside _call_mistral, caught by compose_email's own except Exception (109), and the row lands in scrape_failed with error_message = "Composition failed: 'MistralClient' object has no attribute 'chat'". Even if .chat existed, chat_completion returns the raw parsed JSON body as a dict (result = await response.json(), client.py 470), which has no .content attribute either, so line 153's hasattr(response, "content") check would fall through to str(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 own MistralClient, and has never been exercised end to end (Tests).
  • 2026-09-12 (measured at 62ba4fc4, composition_service.py 133 vs core/config.py 937): _call_mistral hardcodes model="mistral-small-latest", which is a direct-Mistral-API model id (MistralClient.MODELS, client.py 209 to 213). The default transport as of this commit is PRIMA_LLM_PROVIDER=infomaniak (core/config.py 689), whose base URL is Infomaniak AI Services, not api.mistral.ai (services/mistral/client.py module docstring, 1 to 17); the sibling SPARK_MISTRAL_MODEL setting (core/config.py 937) is explicitly documented as needing "an Infomaniak model id by default, not Mistral's own 'mistral-small-latest'" for the same client class. This MistralRequest.model value is not read by chat_completion anyway (it always sends self.model, resolved from infomaniak_chat_model(), client.py 454), so today it is inert, but it would be a live mismatch the moment _call_mistral is rewritten to call chat_completion correctly 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 off campaign.sender_name or any workspace field even though OutreachCampaign.sender_name exists (outreach_model.py 60) 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 singleton composition_service (202).
  • alchemy_labs/backend/models/outreach_model.py (146 lines, whole file in scope): CampaignStatus/OutreachEmailStatus enums (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_generate route (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.

  1. grep -n "def _call_mistral" backend/services/outreach/composition_service.py prints 127; grep -n "client.chat(request)" backend/services/outreach/composition_service.py prints 151.
  2. grep -n "^ async def \|^ def " backend/services/mistral/client.py | grep -c "def chat(" prints 0, confirming no method literally named chat exists on MistralClient (its chat-shaped methods are chat_completion, stream_chat_completion, raw_chat_completion, generate_with_context, all listed by the same grep without the chat( filter).
  3. 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.
  4. 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.
  5. A live run (compose_email against a real OutreachEmail/Lead pair with PRIMA_LLM_PROVIDER resolvable) 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; the AttributeError claim in Gotchas is established statically (checks 1 to 3 above), not by executing the function.
  6. grep -c "^ async def \|^ def " backend/services/mistral/client.py prints 18 (the full method count on CostTracker, RateLimiter and MistralClient combined, 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.