Language-model assistance¶
Turning a described problem into a structured one is the part of MCDA that is genuinely hard to automate. Naming plausible criteria, giving each a direction, drafting a first set of weights — a model’s breadth helps here, and a wrong answer is cheap to spot.
A model is not useful as an authority. It returns weights with the same confidence whether they were considered or invented, and nothing in the output distinguishes the two. So this module inverts the usual arrangement: the model proposes, the package verifies, and tells you what the proposal is worth.
Nothing here is re-exported from the package namespace. Using it takes an
explicit from mcdakit.ai import ... that stays visible in your source —
an MCDA result is often used to justify a decision to someone else, and
whether a model shaped the inputs belongs in the record.
Connecting a model¶
ask is any callable that takes a prompt string and returns the reply
string. No vendor is assumed, nothing is installed, and mcdakit acquires
no LLM dependency.
Ask = Callable[[str], str]
That is the entire interface, and it commits the package to no vendor.
Adapters ship with the package, so for the common providers you need write nothing at all. They speak each vendor’s HTTP API through the standard library — no SDK to install, and NumPy remains the only dependency:
from mcdakit.ai_providers import openai, anthropic, google, ollama
ask = anthropic() # reads ANTHROPIC_API_KEY
ask = openai() # reads OPENAI_API_KEY
ask = google() # reads GOOGLE_API_KEY or GEMINI_API_KEY
ask = ollama("llama3") # local, no key, nothing leaves the machine
ask = anthropic(model="claude-opus-5", api_key="sk-...") # explicit
Where the key goes¶
Three ways, in order of preference.
A .env file, which is what most projects use locally:
# .env — add it to .gitignore, and never commit it
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
GOOGLE_API_KEY=...
from mcdakit.ai_providers import load_env, anthropic
load_env() # finds ./.env, or the nearest one in a parent directory
ask = anthropic()
load_env understands KEY=value, export KEY=value, quoted values,
comments and blank lines. It does not overwrite a variable already set in
the environment unless you pass override=True — the real environment should
win over a file in a checkout, or deploying becomes surprising. A missing
file is not an error, because production usually has none. No dependency is
added: the parsing is a dozen lines.
Environment variables, which need no code at all:
export ANTHROPIC_API_KEY=sk-ant-...
Function |
Variable |
|---|---|
|
|
|
|
|
|
|
none — local |
|
none, unless the server wants one |
Explicitly, when a secrets manager already owns the key:
ask = anthropic(api_key=vault.get("anthropic"))
An explicit key beats the environment, which beats the .env file. Get it
wrong and the error names the variable rather than surfacing a vendor 401:
ProviderError: No API key for Anthropic. Set the ANTHROPIC_API_KEY
environment variable, or pass api_key= explicitly. For a local model
that needs no key, use ollama() or openai_compatible().
Local and self-hosted models¶
ollama() covers the usual local case. For anything exposing the
OpenAI-compatible /chat/completions endpoint — vLLM, LM Studio,
llama.cpp’s server, Groq, Together, OpenRouter, DeepSeek, Mistral — one
adapter reaches all of them:
from mcdakit.ai_providers import openai_compatible
ask = openai_compatible("mistral-7b", "http://localhost:8000/v1") # vLLM
ask = openai_compatible("llama-3.3-70b", "https://api.groq.com/openai/v1",
api_key="gsk_...")
Keeping the problem description, criteria and measurements on your own machine matters for commercially sensitive decisions, which is a large share of real MCDA work.
What the adapters handle¶
Rate limits. A 429 or a transient 5xx is retried with exponential backoff, honouring the provider’s own
Retry-After. A 400 is not retried: it will fail identically however often it is sent.Missing keys.
ProviderError: No API key for Anthropic. Set the ANTHROPIC_API_KEY environment variable...rather than a vendor 401.Changed reply shapes. An unexpected response names the provider and shows what arrived, instead of an
IndexErrorfrom inside an adapter.Temperature 0 by default, because
samples=2measures whether the model is consistent — sampling noise would corrupt that measurement.
Verified against a real local model¶
The adapters are tested against a local HTTP server answering in each vendor’s response format, and the Ollama path has been exercised end to end against a running model. That test is worth describing, because it went wrong in the useful way.
Asked to name criteria for choosing a laptop, a small local model
(qwen2.5:0.5b) did well — four criteria, correct directions, all parsed:
['price' (cost), 'performance' (benefit), 'size' (cost), 'features' (benefit)]
Asked for weights, the same model replied with the single token ['a'].
The package refused it by name:
AiError: The model omitted a weight for 'price'. It replied with: ['a']
No fallback weights were invented, no NaN entered the ranking, and the
error says which criterion was missing and what actually arrived. A weak model
fails loudly here rather than quietly producing a plausible-looking
recommendation, which is the whole point of the arrangement.
Writing your own¶
Nothing requires you to use them. Any callable works, so an in-house gateway, a cached fixture or an SDK you already trust drops straight in:
def ask(prompt: str) -> str:
return my_company_gateway.complete(prompt)
Replies arrive as real models write them — wrapped in markdown fences, with a sentence of preamble before the JSON. The parsers expect that and extract the JSON regardless.
Proposing criteria¶
from mcdakit.ai import propose_criteria
proposal = propose_criteria(
"choosing a cloud hosting provider for a mid-sized SaaS company",
ask=ask,
samples=2, # ask twice; the agreement between replies is reported
)
print(proposal)
10 criteria proposed:
Monthly cost (cost, weight 0.18)
Uptime / reliability SLA (benefit, weight 0.16)
Performance (benefit, weight 0.12)
...
! discarded 'Colour': direction must be one of benefit, cost
agreement across replies: 80%
not applied — construct a Decision to accept
Each suggestion is parsed into a validated
Criterion, so a malformed or out-of-range answer never
reaches proposal.criteria — it lands in proposal.rejected with the
reason. Six proposed criteria of which two were malformed is a different
signal from four clean ones, and dropping the difference would hide it.
samples=2 asks the same question twice and reports the share of names common
to every reply. Low agreement means the model is improvising, which is worth
more than any single answer.
proposal.criteria # validated Criterion objects
proposal.rejected # ((name, reason), ...)
proposal.agreement # 0.8
proposal.accepted # always False — see below
Proposing weights, and checking them against the data¶
from mcdakit.ai import propose_weights
weights = propose_weights(
"choosing a cloud hosting provider", criteria, ask=ask, matrix=matrix
)
weights.weights # array, normalised to sum to 1
weights.stability # the sensitivity() report USING these weights
weights.disagrees_with # ('entropy', 'critic', 'std')
This is the part worth reading twice. stability is a full
sensitivity() report computed with the model’s weights:
weights.stability["level"] # 'moderate'
weights.stability["weakest"] # 'Uptime SLA'
A model’s weights can be entirely plausible and still produce a recommendation that a small correction overturns. You learn that before relying on them.
disagrees_with names the objective schemes that order the criteria
differently. A model that ranks importance unlike entropy, CRITIC and
standard deviation is not necessarily wrong — but the divergence is worth
seeing.
To apply the same checks to weights from any source, human included:
from mcdakit.ai import critique_weights
critique_weights(my_weights, criteria, matrix) # no model involved
Pairwise comparison, which can be checked¶
Asking for a weight vector gives you an answer that cannot be wrong on its own terms. Asking is price more important than quality, and by how much? gives you answers that can contradict each other — and Saaty’s consistency ratio finds the contradiction by arithmetic:
from mcdakit.ai import propose_comparisons
judgements = propose_comparisons("choosing a host", criteria, ask=ask)
print(judgements)
6 pairwise judgements, consistency ratio 0.022 (consistent)
Monthly cost 0.2394
Uptime SLA 0.5215
Support quality 0.1530
Migration effort 0.0860
not applied — construct a Decision to accept
If the model says price matters three times quality, quality three times
delivery, and price only twice delivery, that is incoherent. The ratio detects
it without anyone knowing what the right weights were. Above 0.10 the
proposal prints INCONSISTENT and says to ask again.
Simulating a panel¶
Human expert panels are expensive, slow and hard to reproduce. Scoring the problem once per persona gives a cheap approximation — provided the spread between personas is reported, so a “panel” that agreed exactly is visible as having added nothing:
from mcdakit.ai import simulate_panel
panel = simulate_panel(
"choosing a cloud hosting provider",
criteria,
personas=["CFO focused on budget discipline",
"VP of Engineering focused on reliability",
"Head of Customer Success"],
labels=["AWS", "Azure", "DigitalOcean"],
ask=ask,
)
print(panel)
3 simulated participants:
CFO focused on budget discipline
VP of Engineering focused on reliability
Head of Customer Success
spread across personas: 0.447
not applied — pass to group_rank() to combine
The personas come back as ordinary
Participant objects and go through exactly the same
group_rank() machinery as a real panel. A simulated panel
is not a substitute for asking people; it is a way to see whether the outcome
is sensitive to perspective before you spend their time.
Writing it up, with the figures checked¶
from mcdakit.ai import narrate
written = narrate(result, ask=ask, sensitivity_report=report)
written["text"] # the paragraph
written["faithful"] # False
written["unsupported_numbers"] # ('0.7212', '0.6845', '0.5130')
The model is given the computed facts and asked only to write them up. Nothing is left for it to analyse, so the single failure available to it is misstating a number — and that is checkable. Every numeric literal in the reply is compared against the figures supplied, and any that does not appear is listed.
This is not hypothetical. Asked to describe a result where DigitalOcean won
with 0.8049, a model produced a fluent paragraph awarding the win to AWS at
0.7212. faithful was False and every invented figure was named.
Read unsupported_numbers before pasting the text into a report.
What the module will not do¶
proposal.accepted # False. Always.
Every function returns a proposal. Nothing is applied automatically, and
there is no setting that changes this. Accepting a suggestion means
constructing a Decision from it yourself — a line of
code you write, in a file someone can read.
A package whose stated purpose is refusing answers it cannot stand behind does not get to make an exception because the answer came from a model.
A complete run¶
from dataclasses import replace
from mcdakit import Decision, rank, sensitivity
from mcdakit.ai import propose_criteria, propose_weights, narrate
# 1. The model suggests a structure; you read it.
proposal = propose_criteria("choosing a cloud host", ask=ask, samples=2)
criteria = [c for c in proposal.criteria if c.name != "Vendor lock-in risk"]
# 2. You supply the data. The model never sees or invents measurements.
matrix = [[4200, 99.99, 8, 6], [3800, 99.95, 7, 4], [1500, 99.90, 5, 2]]
# 3. The model drafts weights; the package checks them against that data.
weights = propose_weights("choosing a cloud host", criteria, ask=ask,
matrix=matrix)
if weights.stability["level"] == "fragile":
print("the recommendation turns on a small weight change — revisit")
# 4. You accept, explicitly.
criteria = [replace(c, weight=w) for c, w in zip(criteria, weights.weights)]
result = rank(Decision(matrix, criteria, ["AWS", "Azure", "DigitalOcean"]),
method="topsis")
# 5. The write-up is checked against the arithmetic.
written = narrate(result, ask=ask, sensitivity_report=sensitivity(result))
assert written["faithful"], written["unsupported_numbers"]
The model shapes the structure. Every number that decides the outcome is either measured by you or computed by the package.