My Paper Reader Answered Questions for Weeks. I Never Checked If It Was Right. Developer Talkit's paper-reading app, which reads research papers aloud and answers spoken interruptions, had no real answer-quality evaluation for weeks: its two test scripts only checked that an answer returned, so an answer inventing a BLEU score would have passed. The developer moved the answer logic out of the /api/ask handler into app/qa.py, wrote ten questions with known answers and four metrics, and added span attributes for backend scoring, with the first run surfacing a failure mode that had been present all along. Talkit sends the entire paper (capped at 60,000 characters; the Attention paper is 30,138 after reference-list removal) into the model context with no vector store, chunking, or embeddings on the answer path. TL;DR I’ve been chipping away at a small project called Talkit. It reads research papers aloud and lets you interrupt it. You say “wait, why do they scale that?”, the narration stops, it answers in two or three spoken sentences, and then it picks up where it left off. That part works. I use it. What I could not have told you, for weeks, was whether the answers were any good. The evidence I had was two scripts. One uploads the Attention paper, asks a single question, and checks that an answer comes back. The other does the same in Hinglish. An answer that invented a BLEU score would have passed both. So would an answer that confidently explained the wrong paragraph. The endpoint returned 200 and the suite stayed green, and a green suite quietly becomes the thing you trust. The other reason I put it off is that the RAG advice I kept reading didn’t fit. Every guide opens with chunking and embeddings and retrieval metrics, and Talkit has no vector store at all: the paper fits in the model’s context, so it sends the whole thing, which leaves half the standard eval playbook describing a component I don’t have. So I finally sat down and measured the thing I actually built. This post is what that took: pulling the answer path into one function, writing ten questions with known answers, four metrics that each catch a different failure, and span attributes that let a backend score answers as they happen. None of it is elaborate. It’s the smallest setup that would tell me when an answer is wrong, and the first run turned up a failure mode that had been sitting there the whole time. Here’s the path a question takes: Steps 2 and 3 are the RAG part, and the answer that comes out of them is what this post evaluates. Talkit has no chunking and no embeddings on the answer path, and that was a deliberate choice. Uploads are capped at 60,000 characters. The Attention paper, after the reference list is cut, comes to 30,138. That fits in a model’s context with plenty of room, so the model sees all of it. The bigger reason is the kind of question I actually ask it. Halfway through a paper I’ll say “why does that matter” or “what’s this number”. Those questions retrieve badly by similarity, because the words that matter “that”, “this” point at what was just read out, not at anything an embedding can match. So the current passage goes in as its own labelled block. That passage is the retrieval signal. Every question sends the whole paper, so input tokens scale with paper length, not question complexity. The 60k cap also rules out books and long theses. If Talkit ever answers questions across a whole library, or takes documents past the cap, that’s when I’d go add a vector store, and neither of those is true today. The answer logic used to live inline in the /api/ask handler. An eval can't call a FastAPI route without a database, a session, and a stored key. And an eval that reimplements the prompt assembly measures a copy, which drifts the first time I edit the original. So it moved into app/qa.py, and the route calls it: python async def answer question provider: TextProvider, lang: str, paper: str, passage: str, question: str, history: list dict | None = None, user id: str | None = None - str: with tracer.start as current span "ask.answer" as span: messages = build messages paper, passage, history or , question raw = await provider.converse answer system lang , messages, max tokens=ANSWER MAX TOKENS answer = speakable raw, question describe llm span span, name="ask", model=provider.model, provider=provider.label, question=question.strip , answer=answer, grounding= passage, paper , user id=user id, metadata={"lang": lang, "history turns": len history or }, return answer build messages puts the paper and passage first, then up to six recent turns, then the question: python def build messages paper: str, passage: str, history: list dict , question: str - list dict : messages = {"role": "user", "content": f"FULL PAPER:\n{paper}\n\nCURRENT PASSAGE:\n{passage}"}, {"role": "assistant", "content": "Understood. I have the paper and I know where the listener is."}, for turn in history -HISTORY TURNS: : role = "user" if turn.get "role" == "user" else "assistant" messages.append {"role": role, "content": str turn.get "content", "" :HISTORY TURN CHARS } messages.append {"role": "user", "content": question.strip } return messages And speakable removes markdown plus a restated question. The system prompt already forbids markdown and the model mostly complies, which isn't good enough for text that goes straight into a text-to-speech voice. The refactor was pure motion: behavior didn’t change, and the existing 85 tests still pass. I added six more for this module, which brings the offline suite to 91. Talkit already sends OpenTelemetry traces. Spans are created unconditionally, and with no exporter configured the global provider is a no-op. Picking a backend is environment variables, with no vendor SDK in the code. I picked Confident AI because DeepEval, which I was already using for the evals, is theirs, so the eval runs and the traces land in the same project. Langfuse, Phoenix and others accept OpenTelemetry too, and everything up to this point would work with any of them. Pointing it at Confident AI: OTEL EXPORTER OTLP ENDPOINT=https://otel.confident-ai.comOTEL EXPORTER OTLP HEADERS=x-confident-api-key=