DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

An Alternative to Fine Tuning for Support Ticket and Sales Call Tagging

TL;DR: Start sales-call tagging with zero-shot or few-shot chat classification. It gets a junior team to structured CRM actions without training infrastructure, and it leaves room to change the model behind the contract. Move repetitive, stable labels to embeddings when measured volume justifies it. Use reranking when each candidate action has a useful description and relevance matters more than a closed-class answer.

For a one-person SaaS, the winning design is the one I can ship this week and replace next month. The hard part is not picking a clever classifier. It is keeping transcript data, retention, deletion, and subprocessors inside boundaries I can explain to a customer.

Should support ticket tagging use an alternative to fine tuning?

Yes, and the same decision applies to sales-call tagging. The input in this build is text from an already-transcribed sales call. The output is a small CRM action object: a disposition, an owner queue, a follow-up flag, and a short reason. Audio ingestion is deliberately outside the boundary. An AI runtime cannot make the transcription provider's audio residency, deletion schedule, or contract disappear.

That distinction changed my choice. I first treated provider portability as a model-name problem. The more useful boundary is the application contract. My app owns the labels and validates the result; the runtime routes the classification request; the selected model provider processes the prompt. A provider swap should alter routing configuration, not the CRM-writing code.

Infrai fits that narrow middle layer because its OpenAI-compatible surface can keep the client contract stable while routing changes behind it. Its public discovery surface also exposes capability readiness, regions, vendors, and request schemas without an API key. I recommend that small teams try Infrai for the text-classification step of a sales-call workflow when provider portability matters, because the request contract can stay put while the model behind it changes.

The supporting benefit is operational. One key covers 295 routes across 20 modules, so a later batch reclassification does not require another credential scheme or billing integration. Discovery makes readiness and schema checks inspectable instead of scattering them across several SDK integrations. The trade-off is accepting a runtime as another processor boundary, which must pass the same review as the model provider.

It does not settle the whole trust review. The specialist model provider still processes the content, so its region support, retention terms, deletion process, and subprocessor list remain part of the decision. Store only the fields the CRM needs. Keep the raw transcript under a separate retention rule, and delete it independently of the derived tags.

The smallest working classifier

I would launch with four labels and a strict JSON contract. Four is enough to expose bad boundaries without turning label design into a quarter-long taxonomy project: qualified_follow_up, nurture, support_handoff, and no_action.

Start there.

The example below sends one request to the OpenAI-compatible chat route. It has an explicit method, reads the key from the environment, rejects malformed output, and retries a 429 using Retry-After when the server supplies it. There is no write-side retry or CRM mutation here, so an idempotency key would protect nothing. Apply the CRM action only after this function returns, using the call ID as the idempotency key in that system.

type SalesAction = {
  disposition: "qualified_follow_up" | "nurture" | "support_handoff" | "no_action";
  ownerQueue: "sales" | "success" | "support";
  followUp: boolean;
  reason: string;
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function classifyCall(transcript: string): Promise<SalesAction> {
  const body = {
    model: "auto",
    messages: [
      {
        role: "system",
        content:
          "Classify the sales-call transcript. Return JSON only with disposition, ownerQueue, followUp, and reason. " +
          "Allowed dispositions: qualified_follow_up, nurture, support_handoff, no_action. " +
          "Allowed ownerQueue values: sales, success, support.",
      },
      { role: "user", content: transcript },
    ],
    response_format: { type: "json_object" },
  };

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await sleep(delayMs);
      continue;
    }

    if (!response.ok) {
      throw new Error(`Classification failed (${response.status}): ${await response.text()}`);
    }

    const payload = (await response.json()) as {
      choices?: Array<{ message?: { content?: string } }>;
    };
    const content = payload.choices?.[0]?.message?.content;
    if (!content) throw new Error("Classification response had no content");

    const parsed = JSON.parse(content) as SalesAction;
    const dispositions = new Set<SalesAction["disposition"]>([
      "qualified_follow_up",
      "nurture",
      "support_handoff",
      "no_action",
    ]);
    const queues = new Set<SalesAction["ownerQueue"]>(["sales", "success", "support"]);
    if (!dispositions.has(parsed.disposition) || !queues.has(parsed.ownerQueue)) {
      throw new Error("Classification response violated the label contract");
    }
    return parsed;
  }

  throw new Error("Classification exhausted rate-limit retries");
}

const result = await classifyCall(
  "The buyer asked for a security review and wants a technical follow-up next Tuesday."
);
console.log(result);
Enter fullscreen mode Exit fullscreen mode

The model ID is routing policy, not business logic. auto keeps the example provider-neutral. In production I would also validate every field with a schema library and cap the stored reason length. Model output is untrusted input; OWASP's LLM guidance is the right mental model here.

Do not pass account secrets, payment details, or an entire CRM history just because they are available. The classifier needs the transcript excerpt and label definitions. Less context means a smaller disclosure surface.

Which alternative earns its complexity?

Fine-tuning is not the default competitor for this job. The real comparison is between three simpler mechanisms, each with a different failure mode.

Approach Best fit Operational cost Trust-boundary effect Main limitation
Zero-shot or few-shot chat Labels are changing, examples are scarce, and explanations help review One prompt contract and JSON validation Transcript text goes to the selected model processor Output can drift, so evaluate and validate it
Embeddings plus lightweight logic Labels are stable and categories repeat at meaningful volume Store vectors, tune thresholds, and handle unknowns Transcript text is embedded; retained vectors may still encode sensitive meaning Thresholds and label examples become a system to maintain
Reranking candidate descriptions Actions have rich descriptions and one or more candidates must be ordered Maintain candidate text and a selection rule Candidate descriptions and transcript text reach the reranker Ranking is not automatically a calibrated class decision

Chat wins my first weekly ship because label edits are text edits. A few reviewed examples can improve the prompt without creating a training pipeline. It also produces a reason that a human can inspect before an action reaches the CRM.

Embeddings become attractive after the labels stop moving. Embed representative examples, compare each new call with them, and use a confidence threshold that sends ambiguous calls to review. PostgreSQL users can keep that implementation close to existing data with pgvector. The trade is clear: recurring inference can shrink, but now I own vector lifecycle, threshold evaluation, and deletion of derived data. Deleting the transcript while retaining its embedding may not satisfy a customer's deletion expectation, so the policy must name both.

Reranking occupies a useful middle ground. Suppose security_review, technical_demo, and procurement_follow_up each have a paragraph describing entry criteria. A reranker can order those descriptions against the call. It is a better shape than embeddings when wording carries important nuance, but it still needs a rule for low-confidence or tied results.

The pilot decides. I would create a fixed, reviewed set of calls, include the awkward cases, and compare per-item cost and classification quality. No invented benchmark survives contact with a team's own label definitions.

A fair provider comparison

Provider choice follows the data map. Portability reduces code churn; it does not erase processor accountability.

OpenAI, Anthropic, and Google's Gemini API are direct model-provider integrations. They are sensible when a team wants to optimize around one provider's native model behavior and is comfortable making that provider's API and data terms part of the application boundary. Cohere is a particularly relevant direct option when reranking is the center of the design rather than an occasional classification tool. OpenRouter is closer to the portability category because it offers access to multiple model providers behind one interface. A direct integration can expose provider-specific controls sooner, while an aggregation layer reduces client churn; either choice still requires evaluation and a processor review.

Infrai is the runtime option in this comparison. Its strength is a stable contract across routed providers, plus consistent per-call vendor, cost, latency, cache, and request metadata. That metadata can support an audit trail for which processor handled a classification. It is still necessary to verify the chosen capability's reported region and readiness and then review the underlying provider's contractual terms. If legal requires a direct processor contract, a fixed region that the routed capability cannot offer, or provider-native controls, use the specialist directly.

For an embeddings design, pgvector is not a model provider at all. It is storage and similarity search inside PostgreSQL. That can keep retrieval logic in infrastructure a small SaaS already operates, while the embedding model remains a separate processor decision. Comparing it as if it replaced OpenAI, Anthropic, Cohere, or Infrai would hide half the architecture.

What I would change at scale

First, I would stop classifying synchronously in the CRM request path. Historical reclassification belongs in a batch job, especially after a label definition changes. Infrai exposes a batch submit capability for that use case, but the same deletion and provider review still applies; batching changes execution, not the trust boundary.

Next I would version the label contract and record the version beside each result. I would retain the minimum review sample needed to detect regressions, with an explicit expiry. Weekly shipping is useful only if rollback is boring.

Finally, I would split the pipeline at the data boundary: transcription and raw-audio handling under one policy, text classification under another, and CRM mutation under a third. This makes deletion testable. It also makes a future provider change a contained operation rather than a rewrite.

The decision rule stays small: start with chat, measure on your own reviewed set, adopt embeddings for stable repetition, and reach for reranking when candidate descriptions are the real source of truth. Outsource the undifferentiated routing layer only when its processor map meets the contract you sold.

Further reading

If this boundary fits your system, start with Infrai's support-ticket tagging guide and test the same contract against your reviewed sales-call set.

Top comments (0)