The Q&A view — the page a customer is sent¶
The window is an evaluator's console: modes, panes, a side-by-side with a vector search, the agent's tool calls. The Q&A view is the same window served as another page, for somebody who was sent a link to ask a question: one question box, the answer, a follow-up box. It stands in front of a directory of people.
.venv/bin/python demo/ui_server.py --workspace <id> --workspace-kind people \
--view qa --examples demo/qa_examples.json \
--visitors /var/limma-demo/visitors.json --cap-visitor-usd 5 --cap-day-usd 20
The agent behind it is the people search of People search — the same loop, the same prompt, the same verbs. What differs is what is drawn, what the page is sent, and what the server answers.
What the page says¶
- The first line answers. "7 people fit. 2 more are close." The numbers are the search's own counts — of everyone who fits and of everyone one condition short — never the length of what is listed. It lists no caveat. What changes the answer is still said there, because the answer is then another one: a read that is not finished ("7 people fit so far. 40 of 120 profiles have not been read yet"), a part of the question that was not searched, an answer that stopped before it was finished.
- One quiet line under it. "The list is complete." — only where the search licenses
it and every page of the answer is listed. Otherwise "Checked 105 profiles. Some
could not be confirmed either way — details", where details opens, in place, the
reasons the search gives, one a line, each with its own count ("The documents of 6
people do not say enough to tell."). The number is what THIS question checked: with a
niche, the profiles it is read for (the reply's
candidates— everybody the ordinary conditions leave, never the size of the directory); without one, everybody the search placed, added from the reply's counts. Unsaid where the reply does not hold it. - The people, from every page the agent fetched, each once — a person the directory
holds two records of is one person: the name, who they are — the role they hold
now and where they are, as the compact reply says it (
about: Search; a place by the vocabulary's own name for it, "Germany" whether a record wrote "Deutschland") — and the words of their documents the answer rests on. A value of the record the question asked for is added only where those lines do not already hold it. - Who is close — one condition short, and sharing something of SUBSTANCE with the question in their own documents — each with who they are, the condition they lack, and whether their documents say nothing for it or state it in a sentence that could not be verified. Somebody whose documents establish only a place is counted, never listed.
[source]on a person opens, in place, the passage of the document with the exact words marked, the field of their record with the table and column it was loaded from, and what else the directory holds for them that their line did not say.- While a new question is being read: one line, "Reading 120 profiles — 45 done".
- A list made without the niche is never shown as the answer. The agent's first call for a brief is its ordinary conditions alone — everybody in a country — and its second adds the niche. When that second search does not come back, the page says one sentence and lists nobody; it does not show the first call's people under the question.
- A failure is one sentence: what happened, and what the visitor can do.
It is drawn from what the search returned, not from what the agent wrote. The agent's own text is shown only where it adds something the list does not say, and never a line that names money, metering, a tool, a model or a field of a reply. Every code a reply carries — a reason a list may be missing someone, why a condition is open, how far a read is — goes through one closed table to a plain sentence; a code the table does not hold is said with a general sentence, never printed.
The page shows no id. The agent is told to name people by their names (below); should its text carry an id anyway, an id the page knows — a person a reply listed, a person a follow-up fetched (each record merged into them included), a person an earlier answer of the conversation listed — is that person's name, said once ("Camille N. Rousseau (414faa227dc946d35295d3e1)" is "Camille N. Rousseau"), and any other id is left out: a record's 24 hexadecimal characters, a document's or a request's, a niche's address. A line with no id in it is drawn as the agent wrote it.
What the server answers, and what it sends¶
In this view the window answers only the routes its page calls. Every other route — the replay list, the comparison, the document panes, the spend read — is a 404 before anything is read. Its config says what the page draws itself from and nothing about the deployment.
from fastapi.testclient import TestClient
from demo import qa, ui_server
window = ui_server.create_ui_app("a-workspace", "http://limma.invalid", workspace_kind="people",
view="qa", examples=qa.load_examples(qa.DEFAULT_EXAMPLES),
record=False)
with TestClient(window) as page:
config = page.get("/api/config").json()
print(sorted(config))
print("view:", config["view"], "| modes:", config["modes"], "| about:", config["directory"])
print("try:", config["examples"][0])
print("the page:", page.get("/").status_code,
"| the replay list:", page.get("/api/runs").status_code,
"| the comparison:", page.post("/api/rag", json={"question": "anything"}).status_code,
"| a document's bytes:", page.get("/api/region", params={"source_id": "x"}).status_code)
print("the page's routes:", ", ".join(ui_server.QA_API_PATHS))
['directory', 'examples', 'modes', 'view', 'workspace_kind']
view: qa | modes: ['people'] | about: the expert directory
try: Who in France has 20+ years marketing infant formula in European markets?
the page: 200 | the replay list: 404 | the comparison: 404 | a document's bytes: 404
the page's routes: /api/session, /api/login, /api/logout, /api/config, /api/run, /api/events/{run_id}, /api/source
The page is sent a projection of a run, not the run. Of a search's compact reply it is sent what it draws; of a search that did not come back — refused, or failed — a code and whether it asked for a niche; of the end of a run, the agent's answer — less any line that names money, metering, a tool or a model — and why the loop stopped; of a failure, a code. What a read cost, the request id, the calls that return more, the map's version, every tool call and its arguments, token counts and the model's name stay on the server — in the run's record and the spend ledger, which are what they are in every view. While a search is running the stream is quiet; after twenty seconds of silence it sends a comment line, so a load balancer does not time it out.
from demo import qa
from docs.examples.niche import EUROPE, NICHE, search
asked = {"constraints": EUROPE, "criteria": [{"text": NICHE}]}
reply = search(client, PEOPLE, detail="compact", **asked)
events = [
{"kind": "attached", "data": {"tools": ["limma_search"]}},
{"kind": "tool_call", "data": {"id": "call-1", "tool": "limma_search", "args": asked}},
{"kind": "tool_result", "data": {"id": "call-1", "tool": "limma_search", "result": reply}},
{"kind": "done", "data": {"answer": "**1 people match**", "stop": "end_turn",
"agent_cost_usd_est": 0.42, "usage_in": 4321, "model": "a-model"}},
{"kind": "error", "data": {"message": "ProviderError: a-model said 529", "code": "model_unavailable"}},
]
stream = qa.PageStream()
for event in events:
sent = stream.feed(event)
print(event["kind"].ljust(12), "->", "nothing" if sent is None else
f"{sent['kind']}: {sorted(sent['data'])}")
sent = qa.PageStream()
sent.feed(events[1])
kept = sorted(sent.feed(events[2])["data"]["reply"])
print("of the reply:", kept)
print("left on the server:", sorted(set(reply) - set(kept) - {"spent"}),
"- and `spent`, on a reply that paid for reads")
attached -> nothing
tool_call -> nothing
tool_result -> reply: ['constraints', 'reply']
done -> done: ['answer', 'stop']
error -> error: ['code']
of the reply: ['candidates', 'close', 'complete', 'criterion', 'detail', 'incomplete_because', 'matched', 'matches', 'others']
left on the server: ['map_version', 'more', 'request_id'] - and `spent`, on a reply that paid for reads
What a visitor types cannot write¶
The agent behind this page is offered the three tools that read — search to find, facts to address, region to read back — and no other, whatever the deployment's own tool list says. The server's default set also holds the document inventory and the two verbs that load things into a workspace; a question typed into a page a stranger was sent is otherwise one prompt away from them.
from demo import agent_demo, qa
from epistem.mcp import server
print("offered in the Q&A view:", ", ".join(qa.READ_ONLY_TOOLS))
print("the server's default set also holds:", ", ".join(sorted(
name for name, tier in server.TOOL_TIERS.items()
if tier == server.CORE and name not in qa.READ_ONLY_TOOLS)))
handed = agent_demo.mcp_server_params("http://limma.invalid", tools=qa.MCP_TOOLS).env
print("handed to the agent's server:", handed[agent_demo.MCP_TOOLS_ENV])
offered in the Q&A view: limma_search, limma_facts, limma_region
the server's default set also holds: limma_connect, limma_documents, limma_ingest
handed to the agent's server: limma_search,limma_facts,limma_region
Give the window a viewer key as well (LIMMA_API_KEY): the tool list is what the
agent is offered, the key is what the API allows, and a customer's page should be held by
both. What a viewer key changes is worth knowing before a customer finds it. Reading
profiles for a niche stores judgments, which is a write, so the search refuses a read to
a viewer key (403 criteria_read_needs_write) and serves stored judgments only: the
examples read ahead, any follow-up on them, and every question the record alone answers
are answered as before; a niche nobody has read is not read, and the page says so —
"No profile has been read for this question, and this demo answers only from profiles
already read." A window that should read a visitor's own niche needs an analyst key; the
reads then spend, under the ceilings below.
[source]¶
The compact reply says where the rest of an answer is: its more.entity call returns one
person's rows, quotes and receipts. [source] makes exactly that call — the search the
answer came from, restricted to that person, the niche by its address under a reuse-only
budget, never a brief — and then asks the region verb for each sentence with the text
of its document around it. Nothing is read by a model and nothing is spent.
import httpx
from fastapi.testclient import TestClient
from demo import ui_server
from docs.examples.niche import EUROPE, NICHE, search
reply = search(client, PEOPLE, detail="compact", constraints=EUROPE, criteria=[{"text": NICHE}])
window = ui_server.create_ui_app(PEOPLE, "http://limma.test", workspace_kind="people", view="qa",
record=False, api_key=API_KEY,
transport=httpx.ASGITransport(app=client.app))
with TestClient(window) as page:
found = page.post("/api/source", json={"entity_id": reply["matches"][0]["entity_id"],
"constraints": EUROPE,
"criterion": reply["criterion"]}).json()
print("listed as:", found["listed"])
for field in found["fields"]:
print("record:", field["relation"], [(v["slot"], v["value"], v["table"], v["column"])
for v in field["values"]])
for passage in found["passages"]:
print("document:", repr(passage["text"]))
for mark in passage["marks"]:
print(" marked", mark["labels"], "->", repr(passage["text"][mark["start"]:mark["end"]]))
listed as: fits
record: based_in [('place', 'Germany', 'experts', 'country')]
document: 'Greta Ostrander joined Quellmark Foods in 2001 as a marketing director.\nShe led marketing for baby formula across Germany and Poland until 2023.\nGreta has done this work for more than 20 years.\n'
marked ['marketing'] -> 'led marketing for baby formula'
marked ['baby formula'] -> 'baby formula'
marked ['in European markets'] -> 'across Germany and Poland'
marked ['20+ years'] -> 'for more than 20 years'
A document's file name is not shown: no verb names a document by its id, so the page says whose document a passage is from and shows the passage.
A follow-up¶
One run answers one question. A follow-up asked in the box under an answer is handed what came before, by the page that holds it: the questions so far, the search the last answer came from — its constraints and the niche's address, so the same stored judgments answer the follow-up — and the people that answer listed, by name and id. It is bounded (the first forty people, ten close, six questions) and cleaned on the server before it is written into the agent's question.
Every question this view hands the agent — typed, an example, a follow-up — ends with
how an answer here is written: the answer first, in plain sentences; a person by their
name, never an id; "the directory", never the store's own words or what anything cost;
two entries of the directory are one person only when the visitor asks. The agent's
prompt is the people prompt, whose answer format writes a person as Name (id) for an
analyst, so the note says it comes first. The console keeps its prompt and hands its
questions as typed.
from demo import qa
print(qa.compose_question("only those in Germany", follow_up={
"asked": ["Who has 20+ years marketing infant formula in European markets?"],
"constraints": [{"relation": "based_in", "slot": "place", "op": "eq", "value": "Europe"}],
"criterion": "crit_0123456789abcdef", "total": 2,
"people": [{"id": "e-17", "name": "Greta Ostrander"}, {"id": "e-52", "name": "Annika Solheim"}],
"close": [{"id": "e-08", "name": "Margaux Delacroix"}]}))
only those in Germany
[EARLIER IN THIS CONVERSATION — the visitor now asks a follow-up to it.
Asked before, oldest first:
1. "Who has 20+ years marketing infant formula in European markets?"
The last answer came from this search — send these back for whatever the follow-up keeps:
constraints: [{"relation": "based_in", "slot": "place", "op": "eq", "value": "Europe"}]
criterion: "crit_0123456789abcdef" (the niche's address: sent as criteria=[{"address": "crit_0123456789abcdef"}] it is the same niche, already read)
It found 2 who fit, in the order the answer showed them:
1. Greta Ostrander (entity_id "e-17")
2. Annika Solheim (entity_id "e-52")
It listed these as close (one condition short):
1. Margaux Delacroix (entity_id "e-08")
Where the follow-up changes who is asked for ("only those in …"), answer the WHOLE request again: compile only the new condition from a brief of its own words, then search with the constraints above, the new ones and the criterion above.
Where it asks about someone listed ("the second one"), fetch that person: the search above with entity_ids=[<their entity_id>], detail="full" and criteria_budget={"max_reads": 0} — their rows, quotes and receipts; it reads and spends nothing — and say what it returns in plain sentences.]
[HOW TO ANSWER IN THIS VIEW. The page shows the visitor the list itself, from the search; your words are read beside it (about one person, they are the answer) by a customer of the directory, not by an engineer. In this view these come before the answer format you were given:
- answer the question FIRST, in plain sentences;
- name a person by their name only — never by an id or an entity_id, not even beside the name;
- say "the directory"; never "workspace", "records", "reads", "criteria" or "tools", never how the answer was found, and never what anything cost;
- that two entries of the directory are one person, say only when you are asked.]
An answer about one person ("tell me more about the second one") is not a list: the
agent fetches that person's own rows and answers in sentences, and the page shows those
sentences — guarded as above, an id as the person's name — with that person's [source]
under them.
The example questions, and reading them ahead¶
--examples names a JSON file: what to call the directory, and the "Try:" questions.
{"directory": "the expert directory",
"examples": [{"question": "Who in France has 20+ years marketing infant formula in European markets?",
"constraints": [{"relation": "based_in", "slot": "place", "op": "eq", "value": "France"}],
"niche": "20+ years marketing infant formula in European markets",
"facets": [{"key": "duration", "kind": "duration", "text": "20+ years", "min_years": 20},
{"key": "function", "kind": "condition", "text": "marketing", "value": "Marketing"},
{"key": "domain", "kind": "condition", "text": "infant formula"},
{"key": "market", "kind": "condition", "text": "in European markets", "value": "Europe"}]}]}
An example that states its niche and constraints is prepared: a niche is addressed
by its words — two phrasings of one niche are two criteria, read separately — so the file
states the split, the window hands the agent that split with the question, and the agent
asks for the niche whose judgments are stored. Its facets state the niche's conditions
in the reader's own shape, so no model splits it: the same criterion every time, made
with no model call (a model's split of this niche failed three times in the live
rehearsal, and the example went unread). It is told to ask for it reuse-only first
(criteria_budget: {"max_reads": 0}): a click on an example that was read ahead reads
and spends nothing, under a key that may read and under one that may not. A question a
visitor types is split by the agent like any other.
Read every prepared example ahead once the workspace is loaded, so a visitor's click is answered from stored judgments:
.venv/bin/python -m demo.expert_network.pre_read_examples --workspace <id> \
--examples demo/qa_examples.json --max-usd 5.00
It spends — every profile read is a model call — so --max-usd has no default and the
command refuses without it; the ceiling is for the whole file. It prints, per example,
how the read stopped, how many people fit and what it cost, and says out loud when an
example fits nobody.
An example is shipped only if it returns people. demo/qa_examples.json is the demo
directory's own file: two niches the reader may be developed on — the one the runbook's
walkthrough reads, and one more — each under a location filter. The directory is
synthetic and carries its answer key, so what each example should return is known
before a reader is paid: python -m demo.expert_network.examples_key --n 3000 --seed 23
prints, per example, the profiles its conditions leave, who fits by the key, and who of
them a reader of their documents can find — and exits 1 on an example that fits nobody.
(The first three examples this view shipped with were never checked; two fit nobody.)
from demo import qa
from demo.expert_network import examples_key, pre_read_examples
shipped = qa.load_examples(qa.DEFAULT_EXAMPLES)
print(len(shipped.examples), "examples, all prepared:", all(e.prepared for e in shipped.examples),
"| each with its facets:", all(e.facets for e in shipped.examples))
for niche in sorted({e.niche for e in shipped.examples}):
print(" niche:", niche)
for entry in examples_key.check(shipped, examples_key.build(3000, 23)):
print(f" {entry['status']}: {entry['findable']} of {entry['candidates']:,} profiles — {entry['question']}")
try:
pre_read_examples.main(["--workspace", "a-workspace", "--api-key", "a-key"])
except SystemExit as stopped:
print("reading ahead without --max-usd: exit", stopped.code)
4 examples, all prepared: True | each with its facets: True
niche: 20+ years marketing infant formula in European markets
niche: sold surgical staplers through public tenders in the DACH region
returns_people: 2 of 109 profiles — Who in France has 20+ years marketing infant formula in European markets?
returns_people: 2 of 56 profiles — Who in Switzerland has sold surgical staplers through public tenders in the DACH region?
returns_people: 2 of 209 profiles — Who in Germany or Switzerland has sold surgical staplers through public tenders in the DACH region?
returns_people: 2 of 888 profiles — Who in Europe has 20+ years marketing infant formula in European markets?
reading ahead without --max-usd: exit 2
Hosting¶
The hosted window runs as its own service in this view, with demo/qa_examples.json
(both are in the image). Every deploy rolls it with the API and the worker where the
service exists. Visitors sign in with a name and an access code
(--visitors), exactly as in the console.
What visitors can spend is bounded. The service is started with a ceiling per
visitor and a ceiling per UTC day — window_cap_visitor_usd (default 5) and
window_cap_day_usd (default 20), in US dollars, passed as --cap-visitor-usd and
--cap-day-usd. At a ceiling the next question is refused before anything runs, and the
page says so in one sentence. The ceilings are read from the window's own ledger of what
its runs reported, which lives on the task's disk: a replaced task counts from nothing
again, so they bound a task, not an account. Put a viewer key in the window's key secret
(above).
Rehearsing it on this machine¶
.venv/bin/python -m demo.expert_network.rehearse_qa --out <dir> --n 3000 --seed 23 \
--max-usd <ceiling> --spend-ok
One command: an API over a scratch store with the reader's model door, the directory brought up, the
examples read ahead under the ceiling, one visitor, and this view on a free loopback port — it prints
the address, the name and the access code, and Ctrl-C stops everything it started. It spends, so
without --spend-ok it prints what it would call and what that may spend, and starts nothing.