Coverage for src / quber / playground / ranking.py: 40%

173 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1"""Jev ranking of retrieved chunks for the playground's answer path. 

2 

3Ownership. This module decides which of a question's retrieved chunks the 

4answer model reads when the playground's ranker setting is `jev`. `selection` 

5holds the Haiku equivalent, and `retrieval.retrieve` chooses between them and 

6composes the final context. Both satisfy `ChunkRanker`: the question and the 

7candidates in, indices into the candidates out, most relevant first. 

8 

9How it ranks. Jev, TypeSafe's scoring model, does not read the candidates as 

10one prompt. Every page and every chunk is judged alone, in its own request, 

11so the ranker can afford the whole fused list rather than a window of it. 

12Two passes. Each distinct page in the candidate list is asked whether it has 

13material relevant to the question, and a page answered no is dropped with 

14every candidate on it. Each candidate on a page that passed is then graded 

15on a four-level rubric, and `Ranking.order` keeps the ones at or above the 

16configured cut, highest first. Nothing is added back from fused order when 

17fewer clear the cut: anything added that way is a chunk Jev graded as 

18unrelated or same-topic, or one on a page Jev rejected. 

19 

20What leaves the host. The question, each page's text and each chunk's text 

21are sent to TypeSafe's API, one text per request. TypeSafe states it does not 

22train on requests; zero data retention is an enterprise option not yet taken. 

23 

24Failures. The SDK retries 429, 5xx, connection and timeout errors itself. 

25After each pass, the calls that still failed are run once more. A page that 

26fails twice counts as relevant, so its chunks are still graded. A chunk that 

27fails twice goes after the graded ranking in fused order. Every failed page 

28or chunk is named in the log and the trace, because it may be the one that 

29holds the answer. Only when every call in a pass fails does `rank` raise, and 

30`retrieve` falls back to fused order. 

31""" 

32 

33from __future__ import annotations 

34 

35import asyncio 

36import time 

37from collections import Counter 

38from functools import lru_cache 

39from typing import ( 

40 TYPE_CHECKING, 

41 Any, 

42 Awaitable, 

43 Callable, 

44 Dict, 

45 List, 

46 Optional, 

47 Protocol, 

48 TypeVar, 

49) 

50 

51import httpx2 

52from loguru import logger 

53from pydantic import BaseModel, Field 

54from typesafe_sdk import ( 

55 AsyncTypeSafeClient, 

56 Noul, 

57 NoulAnswer, 

58 RetryPolicy, 

59 Score, 

60 ScoreAnswer, 

61 TypeSafeAPIError, 

62 TypeSafeError, 

63) 

64 

65from quber.agents.langsmith_tracer import usage_metadata_from 

66from quber.playground import db 

67from quber.playground.tracing import run_metadata, tracer 

68from quber.settings import get_settings 

69 

70if TYPE_CHECKING: 

71 from quber.playground.retrieval import RetrievedChunk 

72 

73 

74class ChunkRanker(Protocol): 

75 """The question and a candidate list in; indices into that list out, most relevant first.""" 

76 

77 async def __call__(self, question: str, cands: List["RetrievedChunk"]) -> List[int]: ... 

78 

79 

80# The questions Jev is asked. PydanticAI sends a field's description as the 

81# question and nothing else, so the yes and no criteria are written into the 

82# question text. The wording is positive throughout: Jev's documentation says 

83# negated questions score worse. 

84PAGE_QUESTION = ( 

85 "Does this page have material relevant to the query? " 

86 "Yes when the page discusses the metric, line item, or statement the query asks about. " 

87 "No when the page is about other subjects and nothing on it bears on the query." 

88) 

89 

90# The chunk question is short because the levels carry the meaning. The levels 

91# describe situations, not degrees, and carry no numbers. Level 2 is what keeps 

92# a footnote that defines the metric, the components that reconcile to it, or 

93# the prior period column in the context the answer model reads. 

94CHUNK_QUESTION = "How does this chunk relate to the query?" 

95 

96LEVEL_UNRELATED = "The chunk is about a different subject; nothing in it bears on the query." 

97LEVEL_SAME_TOPIC = ( 

98 "The chunk is on the same topic as the query but gives neither the asked-for item " 

99 "nor anything that qualifies it." 

100) 

101LEVEL_SUPPORTS = ( 

102 "The chunk supports the asked-for item: a definition, a footnote, a component, " 

103 "a reconciliation, or a comparison period for it." 

104) 

105LEVEL_STATES = "The chunk states the asked-for item directly, for the entity and period the query names." 

106 

107 

108# The two questions as the SDK sends them. A Noul is a yes/no question: Jev 

109# returns the probability of yes. A Score is a rubric: the levels are the 

110# criteria in order, 0 first, and Jev returns a probability per level and the 

111# probability-weighted level as the score. The SDK is called directly rather 

112# than through a PydanticAI agent because this path makes a few hundred calls 

113# per question, and the agent's per-call work measured three to four times 

114# the CPU of the call it wraps, enough to saturate one event loop under a 

115# batch of questions. 

116PAGE = Noul(instructions=PAGE_QUESTION) 

117CHUNK = Score( 

118 instructions=CHUNK_QUESTION, criteria=[LEVEL_UNRELATED, LEVEL_SAME_TOPIC, LEVEL_SUPPORTS, LEVEL_STATES] 

119) 

120 

121# A page is relevant when Jev's probability of yes reaches this. It is where 

122# the answer turns from no to yes, not a tuned bar, so it is not a setting. 

123PAGE_THRESHOLD = 0.5 

124 

125 

126class PageScore(BaseModel): 

127 """One page's verdict. `page` is the stored 0-based index.""" 

128 

129 page: int 

130 relevant: bool 

131 # How far Jev's probability sat from the yes/no threshold, 0 to 1. 

132 confidence: Optional[float] = None 

133 error: Optional[str] = None 

134 

135 

136class ChunkScore(BaseModel): 

137 """One candidate's grade. `index` is its position in the candidate list.""" 

138 

139 index: int 

140 chunk_id: str 

141 page: int 

142 # The nearest level, 0 to 3. 

143 grade: Optional[int] = None 

144 # Jev's position on the rubric, the probability-weighted level. This is 

145 # what the cut and the ordering use: two chunks can share a grade and 

146 # differ in score. 

147 score: Optional[float] = None 

148 error: Optional[str] = None 

149 

150 

151class Ranking(BaseModel): 

152 """Everything both passes returned for one question.""" 

153 

154 pages: List[PageScore] 

155 # Every candidate on a page that passed, in candidate order. 

156 scores: List[ChunkScore] 

157 grade_cut: float 

158 model: Optional[str] = None 

159 seconds: float = 0.0 

160 input_tokens: int = 0 

161 # Count per error class or HTTP status, after the SDK's retries and the 

162 # ranker's second attempt. 

163 failures: Dict[str, int] = Field(default_factory=dict) 

164 

165 def order(self) -> List[int]: 

166 """Candidate indices at or above the cut, highest score first, then the 

167 chunks whose calls failed, in candidate order.""" 

168 graded = [c for c in self.scores if c.score is not None and c.score >= self.grade_cut] 

169 graded.sort(key=lambda c: c.score or 0.0, reverse=True) 

170 failed = [c for c in self.scores if c.error is not None] 

171 return [c.index for c in graded] + [c.index for c in failed] 

172 

173 def cleared(self) -> int: 

174 return sum(1 for c in self.scores if c.score is not None and c.score >= self.grade_cut) 

175 

176 def kept_pages(self) -> List[int]: 

177 return [p.page for p in self.pages if p.relevant] 

178 

179 

180@lru_cache(maxsize=1) 

181def client() -> AsyncTypeSafeClient: 

182 """The one SDK client both passes share, so every call reuses one 

183 connection pool. The pool is unbounded: a batch of questions, each with 

184 `concurrency` calls in flight, would otherwise queue on httpx's default 

185 of 100 connections and reopen the ones past its keep-alive cap of 20 on 

186 every call. The per-question semaphore and the batch's question cap are 

187 what bound the load.""" 

188 ts = get_settings().typesafe 

189 if not ts.api_key: 

190 raise RuntimeError("TYPESAFE_API_KEY is not set; the jev ranker needs it.") 

191 http = httpx2.AsyncClient( 

192 timeout=ts.timeout_seconds, limits=httpx2.Limits(max_connections=None, max_keepalive_connections=None) 

193 ) 

194 return AsyncTypeSafeClient( 

195 api_key=ts.api_key, retry=RetryPolicy(max_retries=ts.max_retries), http_client=http 

196 ) 

197 

198 

199async def page_texts(cands: List["RetrievedChunk"]) -> Dict[int, str]: 

200 """The text of each distinct candidate page: its chunks that are not line 

201 records, joined in stored order. 

202 

203 Candidates carry no document key. Their chunk ids are the parse's own 

204 UUIDs, so the document that holds the most of them is the document, and 

205 the pages are read from it. 

206 """ 

207 pages = sorted({c.page for c in cands}) 

208 ids = [c.chunk_id for c in cands] 

209 sql = """ 

210 WITH doc AS ( 

211 SELECT document_id FROM ade_playground.chunks 

212 WHERE chunk_id = ANY(%(ids)s) 

213 GROUP BY document_id ORDER BY count(*) DESC LIMIT 1 

214 ) 

215 SELECT k.page, k.content 

216 FROM ade_playground.chunks k JOIN doc ON doc.document_id = k.document_id 

217 WHERE k.page = ANY(%(pages)s) AND k.chunk_type <> 'line_item' 

218 ORDER BY k.page, k.id 

219 """ 

220 texts: Dict[int, List[str]] = {p: [] for p in pages} 

221 async with db.connect_async() as conn: 

222 for page, content in await (await conn.execute(sql, {"ids": ids, "pages": pages})).fetchall(): 

223 texts[page].append(content) 

224 return {p: "\n\n".join(parts) for p, parts in texts.items()} 

225 

226 

227T = TypeVar("T") 

228 

229 

230class _Tally: 

231 """What every call adds up to: tokens, the resolved model id, failures. 

232 Shaped so `usage_metadata_from` can read it like a run usage.""" 

233 

234 def __init__(self) -> None: 

235 self.input_tokens = 0 

236 self.output_tokens = 0 

237 self.model: Optional[str] = None 

238 self.failures: Counter[str] = Counter() 

239 

240 def record(self, response: Any) -> None: 

241 self.input_tokens += response.usage.input_tokens or 0 

242 self.output_tokens += response.usage.output_tokens or 0 

243 self.model = response.model or self.model 

244 

245 def failed(self, exc: Exception) -> str: 

246 key = str(exc.status) if isinstance(exc, TypeSafeAPIError) else type(exc).__name__ 

247 self.failures[key] += 1 

248 return f"{key}: {exc}" 

249 

250 

251async def _twice( 

252 items: List[T], call: Callable[[T], Awaitable[Any]], failed: Callable[[Any], bool], concurrency: int 

253) -> List[Any]: 

254 """Run `call` over `items` at most `concurrency` at a time, then once more 

255 over the items whose result `failed`. Results in item order.""" 

256 sem = asyncio.Semaphore(concurrency) 

257 

258 async def guarded(item: T) -> Any: 

259 async with sem: 

260 return await call(item) 

261 

262 results = list(await asyncio.gather(*(guarded(i) for i in items))) 

263 retry = [n for n, r in enumerate(results) if failed(r)] 

264 if retry: 

265 again = await asyncio.gather(*(guarded(items[n]) for n in retry)) 

266 for n, r in zip(retry, again, strict=True): 

267 results[n] = r 

268 return results 

269 

270 

271def _levels() -> str: 

272 return "\n".join( 

273 f"{n}: {d}" for n, d in enumerate((LEVEL_UNRELATED, LEVEL_SAME_TOPIC, LEVEL_SUPPORTS, LEVEL_STATES)) 

274 ) 

275 

276 

277def _run_outputs(tally: _Tally, started: float, **fields: Any) -> Dict[str, Any]: 

278 """The fields every traced pass records, plus the pass's own.""" 

279 return { 

280 **fields, 

281 "usage_metadata": usage_metadata_from(tally), 

282 "failures": dict(tally.failures), 

283 "seconds": round(time.perf_counter() - started, 2), 

284 "model": tally.model, 

285 } 

286 

287 

288async def judge_pages(question: str, cands: List["RetrievedChunk"]) -> tuple[List[PageScore], _Tally]: 

289 """Pass 1: one yes/no call per distinct candidate page. One traced run 

290 named `page_rank`, with every verdict, relevant pages first.""" 

291 ts = get_settings().typesafe 

292 tally = _Tally() 

293 texts = await page_texts(cands) 

294 inputs = { 

295 "messages": [{"role": "system", "content": PAGE_QUESTION}, {"role": "user", "content": question}], 

296 "pages": [p + 1 for p in sorted(texts)], 

297 } 

298 

299 async def judge(page: int) -> PageScore: 

300 text = texts.get(page, "") 

301 if not text: 

302 return PageScore(page=page, relevant=True) 

303 try: 

304 response = await client().system_one( 

305 f"QUERY: {question}\n\nPAGE {page + 1}:\n{text}", {"relevant": PAGE}, model=ts.model 

306 ) 

307 except TypeSafeError as exc: 

308 return PageScore(page=page, relevant=True, error=tally.failed(exc)) 

309 tally.record(response) 

310 answer = response.answers["relevant"] 

311 assert isinstance(answer, NoulAnswer) 

312 # Confidence is how far the probability sat from the threshold, 0 at 

313 # the threshold and 1 at either end, so a page that could go either 

314 # way on another call reads as near 0. 

315 return PageScore( 

316 page=page, 

317 relevant=answer.noul >= PAGE_THRESHOLD, 

318 confidence=round(abs(answer.noul - PAGE_THRESHOLD) * 2, 4), 

319 ) 

320 

321 started = time.perf_counter() 

322 async with tracer().llm_run("page_rank", inputs, model=ts.model, extra_metadata=run_metadata()) as run: 

323 pages = await _twice(sorted(texts), judge, lambda p: p.error is not None, ts.concurrency) 

324 judged = [p for p in pages if texts.get(p.page)] 

325 if judged and all(p.error is not None for p in judged): 

326 raise RuntimeError(f"every page call failed: {dict(tally.failures)}") 

327 ordered = sorted(pages, key=lambda p: (not p.relevant, p.page)) 

328 run.outputs = _run_outputs( 

329 tally, 

330 started, 

331 messages=[{"role": "assistant", "content": str([p.page + 1 for p in pages if p.relevant])}], 

332 pages=[{**p.model_dump(), "page": p.page + 1} for p in ordered], 

333 kept=sum(1 for p in pages if p.relevant), 

334 ) 

335 return pages, tally 

336 

337 

338async def grade_chunks( 

339 question: str, cands: List["RetrievedChunk"], kept: set[int] 

340) -> tuple[List[ChunkScore], _Tally]: 

341 """Pass 2: one rubric call per candidate on a kept page. One traced run 

342 named `chunk_rank`, with every score, best first, failed chunks last.""" 

343 ts = get_settings().typesafe 

344 tally = _Tally() 

345 items = [(i, c) for i, c in enumerate(cands) if c.page in kept] 

346 inputs = { 

347 "messages": [ 

348 {"role": "system", "content": f"{CHUNK_QUESTION}\n{_levels()}"}, 

349 {"role": "user", "content": question}, 

350 ], 

351 "chunk_ids": [c.chunk_id for _, c in items], 

352 } 

353 

354 async def grade(item: tuple[int, "RetrievedChunk"]) -> ChunkScore: 

355 i, c = item 

356 prompt = f"QUERY: {question}\n\nCHUNK (page {c.page + 1}, {c.chunk_type}):\n{c.content}" 

357 try: 

358 response = await client().system_one(prompt, {"grade": CHUNK}, model=ts.model) 

359 except TypeSafeError as exc: 

360 return ChunkScore(index=i, chunk_id=c.chunk_id, page=c.page, error=tally.failed(exc)) 

361 tally.record(response) 

362 answer = response.answers["grade"] 

363 assert isinstance(answer, ScoreAnswer) 

364 return ChunkScore( 

365 index=i, 

366 chunk_id=c.chunk_id, 

367 page=c.page, 

368 grade=min(int(answer.score + 0.5), len(CHUNK.criteria) - 1), 

369 score=answer.score, 

370 ) 

371 

372 started = time.perf_counter() 

373 async with tracer().llm_run("chunk_rank", inputs, model=ts.model, extra_metadata=run_metadata()) as run: 

374 scores = await _twice(items, grade, lambda s: s.error is not None, ts.concurrency) 

375 if scores and all(s.error is not None for s in scores): 

376 raise RuntimeError(f"every chunk call failed: {dict(tally.failures)}") 

377 graded = sorted((s for s in scores if s.error is None), key=lambda s: s.score or 0.0, reverse=True) 

378 failed = [s for s in scores if s.error is not None] 

379 cleared = [s for s in graded if (s.score or 0.0) >= ts.grade_cut] 

380 run.outputs = _run_outputs( 

381 tally, 

382 started, 

383 messages=[{"role": "assistant", "content": str([s.chunk_id for s in cleared])}], 

384 scores=[{**s.model_dump(), "page": s.page + 1} for s in graded + failed], 

385 grade_cut=ts.grade_cut, 

386 cleared=len(cleared), 

387 below_cut=len(graded) - len(cleared), 

388 ) 

389 return scores, tally 

390 

391 

392async def score(question: str, cands: List["RetrievedChunk"]) -> Ranking: 

393 """Both passes over the candidates, with everything Jev returned.""" 

394 ts = get_settings().typesafe 

395 started = time.perf_counter() 

396 pages, page_tally = await judge_pages(question, cands) 

397 kept = {p.page for p in pages if p.relevant} 

398 scores, chunk_tally = await grade_chunks(question, cands, kept) 

399 return Ranking( 

400 pages=pages, 

401 scores=scores, 

402 grade_cut=ts.grade_cut, 

403 model=chunk_tally.model or page_tally.model, 

404 seconds=time.perf_counter() - started, 

405 input_tokens=page_tally.input_tokens + chunk_tally.input_tokens, 

406 failures=dict(page_tally.failures + chunk_tally.failures), 

407 ) 

408 

409 

410async def rank(question: str, cands: List["RetrievedChunk"]) -> List[int]: 

411 """Indices into `cands` in the order the answer model should read them.""" 

412 ranking = await score(question, cands) 

413 order = ranking.order() 

414 logger.debug( 

415 "jev ranker: {} pages judged, {} kept; {} of {} candidates graded, {} cleared the cut of {}; " 

416 "{:.1f}s, {} input tokens, failures {}", 

417 len(ranking.pages), 

418 len(ranking.kept_pages()), 

419 len(ranking.scores), 

420 len(cands), 

421 ranking.cleared(), 

422 ranking.grade_cut, 

423 ranking.seconds, 

424 ranking.input_tokens, 

425 ranking.failures or "none", 

426 ) 

427 failed_pages = [p for p in ranking.pages if p.error] 

428 failed_chunks = [s for s in ranking.scores if s.error] 

429 if failed_pages or failed_chunks: 

430 logger.warning( 

431 "jev ranker: {} page calls and {} chunk calls failed twice; pages {}; chunks {}", 

432 len(failed_pages), 

433 len(failed_chunks), 

434 [(p.page + 1, p.error) for p in failed_pages], 

435 [(s.chunk_id, s.error) for s in failed_chunks], 

436 ) 

437 if ranking.failures.get("429"): 

438 logger.warning( 

439 "jev ranker: {} calls were throttled (429) past the SDK's retries; TypeSafe's limits may have changed", 

440 ranking.failures["429"], 

441 ) 

442 return order 

443 

444 

445def rank_sync(question: str, cands: List["RetrievedChunk"]) -> List[int]: 

446 return asyncio.run(rank(question, cands))