Spaces:
Running
QuOTE questions in the enrichment pipeline + one-click eval default (#88)
Browse files* QuOTE-style hypothetical questions join the content-enrichment pipeline
The reranker measurement left five genuinely-buried pages — Linear, Conv2d,
random_split, WeightedRandomSampler, einsum — where a descriptive question
matches nothing the page carries, not even its gloss (Linear still at true
dense rank ~3,400 post-gloss). The literature's index-side answer (QuOTE,
arXiv:2502.10976; HyPE) is to index the QUESTIONS a page answers, turning
question→document matching into question→question matching, paid once at
index time rather than per query like HyDE.
scripts/generate_questions.py mirrors the gloss pipeline exactly: batched,
flushed per batch, resumable via the shared existing_urls_of() check, core
pages first. 5 short task-vocabulary questions per api page, at most one
naming the symbol — the vocabulary bridge is the point. Output is committed
to index/questions.jsonl.
index/embed.py folds the questions into indexed_text() (after the gloss,
before the heading), feeding BOTH the vector and the tsvector — the exact
channel pair the reranker measurement proved decisive (CrossEntropyLoss
flipped via tsvector+rerank). The questions file gets its own recipe stamp
(shared _enrichment_stamp), and the shape change bumps the recipe to v7, so
the next Build Index re-embeds once automatically.
Both workflows run the new pass: Generate glosses gains a questions step
(continue-on-error so a failure never discards the glosses written above),
and Build Index's crawl → enrich → embed sequence now glosses AND questions
new pages before embedding. Provider defaults flipped to openai-compat (hy3
with credit carries the corpus; gemini stays the fallback).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX1gm8fuzK2ZeEWi3Zar1b
* Eval default suite = retrieval+judge: the app-dispatch button must measure everything
The GitHub mobile app cannot set workflow_dispatch inputs — it fires with the
defaults. So the default must BE the measurement one click should give: a new
retrieval+judge suite option (recall/MRR + the answer-quality anchor) becomes
the default, and both job conditions accept it. agentic stays opt-in — it is
the slow, LLM-heavy benchmark that should not run on every click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX1gm8fuzK2ZeEWi3Zar1b
---------
Co-authored-by: Claude <noreply@anthropic.com>
- .github/workflows/build-index.yml +14 -4
- .github/workflows/eval.yml +14 -9
- .github/workflows/generate-glosses.yml +35 -24
- index/embed.py +47 -17
- scripts/generate_glosses.py +9 -3
- scripts/generate_questions.py +149 -0
- tests/index/test_embed.py +34 -0
- tests/scripts/test_generate_questions.py +78 -0
|
@@ -82,16 +82,26 @@ jobs:
|
|
| 82 |
OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
|
| 83 |
TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.llm || 'tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free' }}
|
| 84 |
run: python scripts/generate_glosses.py --limit 0 --batch 30 --sleep 5
|
| 85 |
-
- name:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
if: always()
|
| 87 |
run: |
|
| 88 |
git config user.name "github-actions[bot]"
|
| 89 |
git config user.email "github-actions[bot]@users.noreply.github.com"
|
| 90 |
-
git add -f index/glosses.jsonl
|
| 91 |
-
git diff --cached --quiet || git commit -m "index:
|
| 92 |
git pull --rebase origin main # main may have moved during the run
|
| 93 |
git push
|
| 94 |
-
- name: Embed into Neon (now with
|
| 95 |
env:
|
| 96 |
PYTHONUNBUFFERED: "1"
|
| 97 |
NEON_URL: ${{ secrets.NEON }}
|
|
|
|
| 82 |
OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
|
| 83 |
TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.llm || 'tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free' }}
|
| 84 |
run: python scripts/generate_glosses.py --limit 0 --batch 30 --sleep 5
|
| 85 |
+
- name: Hypothetical questions for new pages (QuOTE-style, incremental)
|
| 86 |
+
continue-on-error: true # same contract as the gloss step
|
| 87 |
+
env:
|
| 88 |
+
PYTHONUNBUFFERED: "1"
|
| 89 |
+
TORCHDOCS_PROVIDER: ${{ inputs.provider || 'openai-compat' }}
|
| 90 |
+
GEMINI_API_KEY: ${{ secrets.GOOGLE_API }}
|
| 91 |
+
OPENAI_COMPAT_BASE_URL: https://openrouter.ai/api/v1
|
| 92 |
+
OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
|
| 93 |
+
TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.llm || 'tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free' }}
|
| 94 |
+
run: python scripts/generate_questions.py --limit 0 --batch 10 --sleep 5
|
| 95 |
+
- name: Commit any new enrichment (also on failure — partial progress is saved)
|
| 96 |
if: always()
|
| 97 |
run: |
|
| 98 |
git config user.name "github-actions[bot]"
|
| 99 |
git config user.email "github-actions[bot]@users.noreply.github.com"
|
| 100 |
+
git add -f index/glosses.jsonl index/questions.jsonl
|
| 101 |
+
git diff --cached --quiet || git commit -m "index: content enrichment from Actions run [skip ci]"
|
| 102 |
git pull --rebase origin main # main may have moved during the run
|
| 103 |
git push
|
| 104 |
+
- name: Embed into Neon (now with glosses + questions in indexed_text)
|
| 105 |
env:
|
| 106 |
PYTHONUNBUFFERED: "1"
|
| 107 |
NEON_URL: ${{ secrets.NEON }}
|
|
@@ -1,23 +1,28 @@
|
|
| 1 |
name: Eval
|
| 2 |
|
| 3 |
# The measurement loop, one button:
|
| 4 |
-
# suite=retrieval (default) — recall@k + MRR over the 100-question set
|
| 5 |
-
# against the LIVE index
|
| 6 |
-
#
|
|
|
|
|
|
|
| 7 |
# suite=agentic — the agent-loop benchmark (loop vs single-shot coverage
|
| 8 |
# delta). Needs LLM keys; slow on free tiers.
|
| 9 |
# suite=judge — LLM-as-judge answer quality (faithfulness / relevance /
|
| 10 |
# citation-correctness) over the grounded path. Needs LLM keys.
|
| 11 |
-
# suite=all —
|
| 12 |
# Results are committed back to main for before/after diffing.
|
| 13 |
on:
|
| 14 |
workflow_dispatch:
|
| 15 |
inputs:
|
| 16 |
suite:
|
| 17 |
-
description: "what to measure
|
|
|
|
|
|
|
|
|
|
| 18 |
type: choice
|
| 19 |
-
options: [retrieval, agentic, judge, all]
|
| 20 |
-
default: retrieval
|
| 21 |
set:
|
| 22 |
description: "retrieval eval set (v1 = 100 questions, v0 = original 13)"
|
| 23 |
type: string
|
|
@@ -63,7 +68,7 @@ permissions:
|
|
| 63 |
|
| 64 |
jobs:
|
| 65 |
retrieval:
|
| 66 |
-
if: ${{ inputs.suite == 'retrieval' || inputs.suite == 'all' }}
|
| 67 |
runs-on: ubuntu-latest
|
| 68 |
timeout-minutes: 15
|
| 69 |
steps:
|
|
@@ -140,7 +145,7 @@ jobs:
|
|
| 140 |
git push
|
| 141 |
|
| 142 |
judge:
|
| 143 |
-
if: ${{ inputs.suite == 'judge' || inputs.suite == 'all' }}
|
| 144 |
runs-on: ubuntu-latest
|
| 145 |
timeout-minutes: 120
|
| 146 |
steps:
|
|
|
|
| 1 |
name: Eval
|
| 2 |
|
| 3 |
# The measurement loop, one button:
|
| 4 |
+
# suite=retrieval+judge (default) — recall@k + MRR over the 100-question set
|
| 5 |
+
# against the LIVE index (plus the rank diagnostic), AND the LLM-as-judge
|
| 6 |
+
# answer-quality anchor. The default is the full one-click measurement
|
| 7 |
+
# because the GitHub mobile app cannot set workflow inputs.
|
| 8 |
+
# suite=retrieval — recall/MRR only. No LLM needed.
|
| 9 |
# suite=agentic — the agent-loop benchmark (loop vs single-shot coverage
|
| 10 |
# delta). Needs LLM keys; slow on free tiers.
|
| 11 |
# suite=judge — LLM-as-judge answer quality (faithfulness / relevance /
|
| 12 |
# citation-correctness) over the grounded path. Needs LLM keys.
|
| 13 |
+
# suite=all — everything.
|
| 14 |
# Results are committed back to main for before/after diffing.
|
| 15 |
on:
|
| 16 |
workflow_dispatch:
|
| 17 |
inputs:
|
| 18 |
suite:
|
| 19 |
+
description: "what to measure. The default runs retrieval AND judge —
|
| 20 |
+
deliberately, because the GitHub mobile app can't set inputs, so the
|
| 21 |
+
default must be the measurement we actually want one click to give:
|
| 22 |
+
recall/MRR plus the answer-quality anchor. agentic stays opt-in."
|
| 23 |
type: choice
|
| 24 |
+
options: [retrieval+judge, retrieval, agentic, judge, all]
|
| 25 |
+
default: retrieval+judge
|
| 26 |
set:
|
| 27 |
description: "retrieval eval set (v1 = 100 questions, v0 = original 13)"
|
| 28 |
type: string
|
|
|
|
| 68 |
|
| 69 |
jobs:
|
| 70 |
retrieval:
|
| 71 |
+
if: ${{ inputs.suite == 'retrieval' || inputs.suite == 'retrieval+judge' || inputs.suite == 'all' }}
|
| 72 |
runs-on: ubuntu-latest
|
| 73 |
timeout-minutes: 15
|
| 74 |
steps:
|
|
|
|
| 145 |
git push
|
| 146 |
|
| 147 |
judge:
|
| 148 |
+
if: ${{ inputs.suite == 'judge' || inputs.suite == 'retrieval+judge' || inputs.suite == 'all' }}
|
| 149 |
runs-on: ubuntu-latest
|
| 150 |
timeout-minutes: 120
|
| 151 |
steps:
|
|
@@ -1,21 +1,26 @@
|
|
| 1 |
name: Generate glosses
|
| 2 |
|
| 3 |
-
#
|
| 4 |
-
#
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
-
#
|
| 8 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
on:
|
| 10 |
workflow_dispatch:
|
| 11 |
inputs:
|
| 12 |
provider:
|
| 13 |
-
description: "LLM provider.
|
| 14 |
-
|
| 15 |
-
|
| 16 |
type: choice
|
| 17 |
-
options: [
|
| 18 |
-
default:
|
| 19 |
gemini_model:
|
| 20 |
description: "gemini model — the free 20/day quota is per model, so
|
| 21 |
re-running with a different one (gemini-2.5-flash-lite,
|
|
@@ -28,19 +33,27 @@ on:
|
|
| 28 |
type: string
|
| 29 |
default: "tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free"
|
| 30 |
limit:
|
| 31 |
-
description: "
|
| 32 |
type: string
|
| 33 |
default: "0"
|
| 34 |
|
| 35 |
permissions:
|
| 36 |
contents: write
|
| 37 |
|
| 38 |
-
concurrency: generate-glosses # two writers would duplicate
|
| 39 |
|
| 40 |
jobs:
|
| 41 |
glosses:
|
| 42 |
runs-on: ubuntu-latest
|
| 43 |
-
timeout-minutes:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
steps:
|
| 45 |
- uses: actions/checkout@v4
|
| 46 |
- uses: actions/setup-python@v5
|
|
@@ -55,23 +68,21 @@ jobs:
|
|
| 55 |
restore-keys: corpus-
|
| 56 |
- run: pip install -e .
|
| 57 |
- name: Write glosses
|
| 58 |
-
env:
|
| 59 |
-
PYTHONUNBUFFERED: "1"
|
| 60 |
-
TORCHDOCS_PROVIDER: ${{ inputs.provider }}
|
| 61 |
-
GEMINI_API_KEY: ${{ secrets.GOOGLE_API }}
|
| 62 |
-
TORCHDOCS_GEMINI_MODEL: ${{ inputs.gemini_model }}
|
| 63 |
-
OPENAI_COMPAT_BASE_URL: https://openrouter.ai/api/v1
|
| 64 |
-
OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
|
| 65 |
-
TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.model }}
|
| 66 |
# batch 30 → ~121 calls for the whole corpus; the run is resumable, so
|
| 67 |
# each dispatch chips away at whatever today's quotas allow
|
| 68 |
run: python scripts/generate_glosses.py --limit "${{ inputs.limit }}" --batch 30 --sleep 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
- name: Commit whatever was written (also on failure — the run is resumable)
|
| 70 |
if: always()
|
| 71 |
run: |
|
| 72 |
git config user.name "github-actions[bot]"
|
| 73 |
git config user.email "github-actions[bot]@users.noreply.github.com"
|
| 74 |
-
git add index/glosses.jsonl
|
| 75 |
-
git diff --cached --quiet || git commit -m "index:
|
| 76 |
git pull --rebase origin main # main may have moved during the run
|
| 77 |
git push
|
|
|
|
| 1 |
name: Generate glosses
|
| 2 |
|
| 3 |
+
# Index-side content enrichment, two passes over the corpus snapshot:
|
| 4 |
+
# 1. glosses — a one-sentence plain-language description per api page
|
| 5 |
+
# (Contextual Retrieval)
|
| 6 |
+
# 2. hypothetical questions — a few task-vocabulary questions each api page
|
| 7 |
+
# answers (QuOTE-style), for pages whose gloss alone couldn't bridge the
|
| 8 |
+
# vocabulary gap (measured 2026-07-09: Linear at dense rank ~3,400
|
| 9 |
+
# post-gloss)
|
| 10 |
+
# Both are batched and resumable: already-covered URLs are skipped and partial
|
| 11 |
+
# progress is committed even on failure — re-run to fill gaps. After they
|
| 12 |
+
# land, run Build Index (the changed recipe stamp forces the one-time full
|
| 13 |
+
# re-embed), then Eval retrieval to measure the effect.
|
| 14 |
on:
|
| 15 |
workflow_dispatch:
|
| 16 |
inputs:
|
| 17 |
provider:
|
| 18 |
+
description: "LLM provider. openai-compat = OpenRouter (hy3 with credit
|
| 19 |
+
carries the full corpus); gemini free = 20 req/day per model. A failed
|
| 20 |
+
provider falls through to the other (both keys set)."
|
| 21 |
type: choice
|
| 22 |
+
options: [openai-compat, gemini]
|
| 23 |
+
default: openai-compat
|
| 24 |
gemini_model:
|
| 25 |
description: "gemini model — the free 20/day quota is per model, so
|
| 26 |
re-running with a different one (gemini-2.5-flash-lite,
|
|
|
|
| 33 |
type: string
|
| 34 |
default: "tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free"
|
| 35 |
limit:
|
| 36 |
+
description: "cover at most N pages per pass this run (0 = all remaining)"
|
| 37 |
type: string
|
| 38 |
default: "0"
|
| 39 |
|
| 40 |
permissions:
|
| 41 |
contents: write
|
| 42 |
|
| 43 |
+
concurrency: generate-glosses # two writers would duplicate enrichment lines
|
| 44 |
|
| 45 |
jobs:
|
| 46 |
glosses:
|
| 47 |
runs-on: ubuntu-latest
|
| 48 |
+
timeout-minutes: 300
|
| 49 |
+
env:
|
| 50 |
+
PYTHONUNBUFFERED: "1"
|
| 51 |
+
TORCHDOCS_PROVIDER: ${{ inputs.provider }}
|
| 52 |
+
GEMINI_API_KEY: ${{ secrets.GOOGLE_API }}
|
| 53 |
+
TORCHDOCS_GEMINI_MODEL: ${{ inputs.gemini_model }}
|
| 54 |
+
OPENAI_COMPAT_BASE_URL: https://openrouter.ai/api/v1
|
| 55 |
+
OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
|
| 56 |
+
TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.model }}
|
| 57 |
steps:
|
| 58 |
- uses: actions/checkout@v4
|
| 59 |
- uses: actions/setup-python@v5
|
|
|
|
| 68 |
restore-keys: corpus-
|
| 69 |
- run: pip install -e .
|
| 70 |
- name: Write glosses
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
# batch 30 → ~121 calls for the whole corpus; the run is resumable, so
|
| 72 |
# each dispatch chips away at whatever today's quotas allow
|
| 73 |
run: python scripts/generate_glosses.py --limit "${{ inputs.limit }}" --batch 30 --sleep 5
|
| 74 |
+
- name: Write hypothetical questions (QuOTE-style)
|
| 75 |
+
# a question failure must not throw away the glosses written above —
|
| 76 |
+
# the commit step below runs regardless and both passes are resumable
|
| 77 |
+
continue-on-error: true
|
| 78 |
+
# 5 questions/page is a longer reply than a gloss → smaller batches
|
| 79 |
+
run: python scripts/generate_questions.py --limit "${{ inputs.limit }}" --batch 10 --sleep 5
|
| 80 |
- name: Commit whatever was written (also on failure — the run is resumable)
|
| 81 |
if: always()
|
| 82 |
run: |
|
| 83 |
git config user.name "github-actions[bot]"
|
| 84 |
git config user.email "github-actions[bot]@users.noreply.github.com"
|
| 85 |
+
git add index/glosses.jsonl index/questions.jsonl
|
| 86 |
+
git diff --cached --quiet || git commit -m "index: content enrichment from Actions run [skip ci]"
|
| 87 |
git pull --rebase origin main # main may have moved during the run
|
| 88 |
git push
|
|
@@ -36,6 +36,13 @@ MAX_EMBED_CHARS = 2000 # bge context is 512 tokens; beyond it is truncated anyw
|
|
| 36 |
# near the descriptive questions users actually ask.
|
| 37 |
GLOSSES_PATH = Path(__file__).parent / "glosses.jsonl"
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
@cache
|
| 41 |
def load_glosses() -> dict[str, str]:
|
|
@@ -45,26 +52,42 @@ def load_glosses() -> dict[str, str]:
|
|
| 45 |
return {row["url"]: row["gloss"] for row in rows}
|
| 46 |
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
return "none"
|
| 54 |
-
return hashlib.sha256(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
# bump the version prefix when indexed_text()'s SHAPE changes → forces a
|
| 58 |
# one-time full re-embed (dims same, so the row-skip check would otherwise keep
|
| 59 |
-
# stale vectors).
|
| 60 |
-
#
|
| 61 |
-
#
|
| 62 |
-
#
|
| 63 |
-
#
|
| 64 |
-
#
|
| 65 |
-
#
|
| 66 |
-
|
| 67 |
-
EMBED_RECIPE = f"v6-{EMBED_MODEL.split('/')[-1]}-g{_gloss_stamp()}"
|
| 68 |
|
| 69 |
|
| 70 |
def chunk_key(unit: dict) -> str:
|
|
@@ -89,7 +112,8 @@ def symbol_from_url(url: str) -> str:
|
|
| 89 |
|
| 90 |
|
| 91 |
def indexed_text(unit: dict) -> str:
|
| 92 |
-
"""What we embed AND tsvector: symbol + synopsis + gloss +
|
|
|
|
| 93 |
|
| 94 |
The symbol gives the page strong lexical weight for symbol-typed queries.
|
| 95 |
The synopsis (the page's own first prose sentence, extracted at chunk time)
|
|
@@ -97,7 +121,10 @@ def indexed_text(unit: dict) -> str:
|
|
| 97 |
to descriptive questions — reference pages' own text is signature-shaped
|
| 98 |
and embeds far from "which loss takes raw logits?", so the plain-language
|
| 99 |
sentences are prepended to every chunk of the page, feeding both the
|
| 100 |
-
vector and the tsvector.
|
|
|
|
|
|
|
|
|
|
| 101 |
"""
|
| 102 |
parts = []
|
| 103 |
symbol = symbol_from_url(unit["url"])
|
|
@@ -108,6 +135,9 @@ def indexed_text(unit: dict) -> str:
|
|
| 108 |
gloss = load_glosses().get(unit["url"], "")
|
| 109 |
if gloss:
|
| 110 |
parts.append(gloss)
|
|
|
|
|
|
|
|
|
|
| 111 |
heading = " > ".join(unit.get("heading_path", []))
|
| 112 |
if heading:
|
| 113 |
parts.append(heading)
|
|
|
|
| 36 |
# near the descriptive questions users actually ask.
|
| 37 |
GLOSSES_PATH = Path(__file__).parent / "glosses.jsonl"
|
| 38 |
|
| 39 |
+
# QuOTE-style hypothetical questions: {url: [task-vocabulary questions the page
|
| 40 |
+
# answers]}, generated by scripts/generate_questions.py and committed.
|
| 41 |
+
# indexed_text() folds them in — question→question matching for pages whose
|
| 42 |
+
# one-sentence gloss couldn't bridge the vocabulary gap (measured 2026-07-09:
|
| 43 |
+
# Linear still at true dense rank ~3,400 post-gloss).
|
| 44 |
+
QUESTIONS_PATH = Path(__file__).parent / "questions.jsonl"
|
| 45 |
+
|
| 46 |
|
| 47 |
@cache
|
| 48 |
def load_glosses() -> dict[str, str]:
|
|
|
|
| 52 |
return {row["url"]: row["gloss"] for row in rows}
|
| 53 |
|
| 54 |
|
| 55 |
+
@cache
|
| 56 |
+
def load_questions() -> dict[str, list[str]]:
|
| 57 |
+
if not QUESTIONS_PATH.exists():
|
| 58 |
+
return {}
|
| 59 |
+
rows = (json.loads(line) for line in QUESTIONS_PATH.open() if line.strip())
|
| 60 |
+
return {row["url"]: row["questions"] for row in rows}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _enrichment_stamp(path: Path) -> str:
|
| 64 |
+
"""Content hash of a committed enrichment file (glosses / questions) —
|
| 65 |
+
folded into the recipe below, so any content change (or first arrival)
|
| 66 |
+
forces the one-time full re-embed itself; no manual version bump to
|
| 67 |
+
forget."""
|
| 68 |
+
if not path.exists():
|
| 69 |
return "none"
|
| 70 |
+
return hashlib.sha256(path.read_bytes()).hexdigest()[:8]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _gloss_stamp() -> str:
|
| 74 |
+
return _enrichment_stamp(GLOSSES_PATH)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _questions_stamp() -> str:
|
| 78 |
+
return _enrichment_stamp(QUESTIONS_PATH)
|
| 79 |
|
| 80 |
|
| 81 |
# bump the version prefix when indexed_text()'s SHAPE changes → forces a
|
| 82 |
# one-time full re-embed (dims same, so the row-skip check would otherwise keep
|
| 83 |
+
# stale vectors). v7: QuOTE-style hypothetical questions join the indexed text
|
| 84 |
+
# (v6 fixed synopsis extraction to read the Sphinx <dl> description line). The
|
| 85 |
+
# model tag is folded in so swapping models is itself a recipe change: a
|
| 86 |
+
# same-dims swap (e.g. two 768d models) still forces a re-embed, and index_meta
|
| 87 |
+
# stays honest about which model's vectors are live (a dims change also
|
| 88 |
+
# rebuilds the table outright — see index/db.ensure_schema). The gloss and
|
| 89 |
+
# question stamps do the same for enrichment-content changes.
|
| 90 |
+
EMBED_RECIPE = f"v7-{EMBED_MODEL.split('/')[-1]}-g{_gloss_stamp()}-q{_questions_stamp()}"
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
def chunk_key(unit: dict) -> str:
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
def indexed_text(unit: dict) -> str:
|
| 115 |
+
"""What we embed AND tsvector: symbol + synopsis + gloss + questions +
|
| 116 |
+
heading + body.
|
| 117 |
|
| 118 |
The symbol gives the page strong lexical weight for symbol-typed queries.
|
| 119 |
The synopsis (the page's own first prose sentence, extracted at chunk time)
|
|
|
|
| 121 |
to descriptive questions — reference pages' own text is signature-shaped
|
| 122 |
and embeds far from "which loss takes raw logits?", so the plain-language
|
| 123 |
sentences are prepended to every chunk of the page, feeding both the
|
| 124 |
+
vector and the tsvector. The hypothetical questions (QuOTE-style) go one
|
| 125 |
+
step further for pages the gloss couldn't move: they put the QUESTION
|
| 126 |
+
phrasing itself into both channels, so a user's descriptive query matches
|
| 127 |
+
question→question instead of question→signature.
|
| 128 |
"""
|
| 129 |
parts = []
|
| 130 |
symbol = symbol_from_url(unit["url"])
|
|
|
|
| 135 |
gloss = load_glosses().get(unit["url"], "")
|
| 136 |
if gloss:
|
| 137 |
parts.append(gloss)
|
| 138 |
+
questions = load_questions().get(unit["url"], [])
|
| 139 |
+
if questions:
|
| 140 |
+
parts.append(" ".join(questions))
|
| 141 |
heading = " > ".join(unit.get("heading_path", []))
|
| 142 |
if heading:
|
| 143 |
parts.append(heading)
|
|
@@ -106,10 +106,16 @@ def parse_glosses(raw: str, n: int) -> dict[int, str]:
|
|
| 106 |
return out
|
| 107 |
|
| 108 |
|
| 109 |
-
def
|
| 110 |
-
|
|
|
|
|
|
|
| 111 |
return set()
|
| 112 |
-
return {json.loads(line)["url"] for line in
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
def main() -> int:
|
|
|
|
| 106 |
return out
|
| 107 |
|
| 108 |
|
| 109 |
+
def existing_urls_of(path: Path) -> set[str]:
|
| 110 |
+
"""URLs already covered in a jsonl enrichment file — the resume check,
|
| 111 |
+
shared with generate_questions.py (same append-and-skip pipeline shape)."""
|
| 112 |
+
if not path.exists():
|
| 113 |
return set()
|
| 114 |
+
return {json.loads(line)["url"] for line in path.open() if line.strip()}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def existing_urls() -> set[str]:
|
| 118 |
+
return existing_urls_of(GLOSSES_PATH)
|
| 119 |
|
| 120 |
|
| 121 |
def main() -> int:
|
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate hypothetical questions for API reference pages (QuOTE-style).
|
| 2 |
+
|
| 3 |
+
Why: the reranker measurement (2026-07-09) left a residue of genuinely-buried
|
| 4 |
+
pages — Linear, Conv2d, random_split, WeightedRandomSampler, einsum — where a
|
| 5 |
+
descriptive question ("what's the standard fully-connected layer?") matches
|
| 6 |
+
NOTHING the page carries, not even its one-sentence gloss (Linear still sat at
|
| 7 |
+
true dense rank ~3,400 post-gloss). The literature's index-side answer
|
| 8 |
+
(QuOTE, arXiv:2502.10976; HyPE) is to index the QUESTIONS a page answers,
|
| 9 |
+
turning question→document matching into question→question matching — paid
|
| 10 |
+
once at index time, not per query like HyDE.
|
| 11 |
+
|
| 12 |
+
What: for every api-kind page in the corpus snapshot, ask an LLM for a few
|
| 13 |
+
short questions a user would ask that this page answers — phrased in everyday
|
| 14 |
+
task vocabulary, mostly WITHOUT naming the symbol (the vocabulary bridge is
|
| 15 |
+
the whole point; the symbol token is already in the index). The questions are
|
| 16 |
+
folded into indexed_text() by index/embed.py — feeding both the page's vector
|
| 17 |
+
and its tsvector, the exact channel pair that flipped CrossEntropyLoss.
|
| 18 |
+
|
| 19 |
+
Output: index/questions.jsonl — {"url", "questions": [...]} per line,
|
| 20 |
+
committed. Same shape as the gloss pipeline: batched, flushed per batch,
|
| 21 |
+
resumable (already-covered URLs are skipped) — rate-limit deaths just mean
|
| 22 |
+
"run it again".
|
| 23 |
+
|
| 24 |
+
Usage: python scripts/generate_questions.py [--limit N] [--batch N]
|
| 25 |
+
(needs an LLM key; corpus snapshot must exist — run the crawl first)
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import argparse
|
| 31 |
+
import json
|
| 32 |
+
import sys
|
| 33 |
+
import time
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 37 |
+
|
| 38 |
+
from dotenv import load_dotenv
|
| 39 |
+
|
| 40 |
+
from scripts.generate_glosses import api_pages, existing_urls_of
|
| 41 |
+
|
| 42 |
+
QUESTIONS_PATH = Path(__file__).parent.parent / "index" / "questions.jsonl"
|
| 43 |
+
|
| 44 |
+
QUESTIONS_PER_PAGE = 5
|
| 45 |
+
QUESTION_MAX_CHARS = 160
|
| 46 |
+
|
| 47 |
+
SYSTEM = (
|
| 48 |
+
"You write search-bridging questions for PyTorch documentation reference "
|
| 49 |
+
f"pages. For each numbered page (symbol/title + excerpt) write "
|
| 50 |
+
f"{QUESTIONS_PER_PAGE} distinct short questions (8-20 words) a PyTorch "
|
| 51 |
+
"user would ask that THIS page answers. Phrase them the way users talk "
|
| 52 |
+
"about the TASK, in everyday ML vocabulary; at most one question may name "
|
| 53 |
+
"the symbol itself — the rest must describe what it does or when you need "
|
| 54 |
+
"it (e.g. for Linear: 'What's the standard fully-connected layer that "
|
| 55 |
+
"applies a weight matrix and bias?'). Reply with a JSON array only, one "
|
| 56 |
+
'item per page, no other text: [{"i": 0, "questions": ["...", ...]}, ...]'
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def batch_prompt(batch: list[dict]) -> str:
|
| 61 |
+
from index.embed import symbol_from_url
|
| 62 |
+
|
| 63 |
+
blocks = []
|
| 64 |
+
for i, page in enumerate(batch):
|
| 65 |
+
symbol = symbol_from_url(page["url"]) or page["title"]
|
| 66 |
+
blocks.append(f"### {i}\nsymbol: {symbol}\nexcerpt: {page['excerpt']}")
|
| 67 |
+
return "\n\n".join(blocks) + f"\n\nJSON array with {len(batch)} question sets:"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def parse_questions(raw: str, n: int) -> dict[int, list[str]]:
|
| 71 |
+
"""{index: [questions]} from the model's reply; malformed items are dropped."""
|
| 72 |
+
start, end = raw.find("["), raw.rfind("]")
|
| 73 |
+
if start == -1 or end == -1:
|
| 74 |
+
return {}
|
| 75 |
+
try:
|
| 76 |
+
items = json.loads(raw[start : end + 1])
|
| 77 |
+
except json.JSONDecodeError:
|
| 78 |
+
return {}
|
| 79 |
+
out: dict[int, list[str]] = {}
|
| 80 |
+
for item in items if isinstance(items, list) else []:
|
| 81 |
+
if not isinstance(item, dict):
|
| 82 |
+
continue
|
| 83 |
+
i, qs = item.get("i"), item.get("questions")
|
| 84 |
+
if not (isinstance(i, int) and 0 <= i < n and isinstance(qs, list)):
|
| 85 |
+
continue
|
| 86 |
+
clean = [q.strip()[:QUESTION_MAX_CHARS] for q in qs if isinstance(q, str) and q.strip()]
|
| 87 |
+
if clean:
|
| 88 |
+
out[i] = clean[:QUESTIONS_PER_PAGE]
|
| 89 |
+
return out
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def main() -> int:
|
| 93 |
+
load_dotenv()
|
| 94 |
+
parser = argparse.ArgumentParser()
|
| 95 |
+
parser.add_argument("--limit", type=int, default=0, help="cover at most N pages (0 = all)")
|
| 96 |
+
parser.add_argument("--batch", type=int, default=10, help="pages per LLM call")
|
| 97 |
+
parser.add_argument("--sleep", type=float, default=2.0, help="pause between calls (s)")
|
| 98 |
+
args = parser.parse_args()
|
| 99 |
+
|
| 100 |
+
from agent.llm import GenerationError, _raw_completion
|
| 101 |
+
from ingest.crawl import CORPUS_DIR
|
| 102 |
+
|
| 103 |
+
if not CORPUS_DIR.exists() or not any(CORPUS_DIR.rglob("*.md")):
|
| 104 |
+
print("corpus snapshot is empty — run the crawl (Build Index) first", flush=True)
|
| 105 |
+
return 1
|
| 106 |
+
|
| 107 |
+
done = existing_urls_of(QUESTIONS_PATH)
|
| 108 |
+
todo = [p for p in api_pages(CORPUS_DIR) if p["url"] not in done]
|
| 109 |
+
if args.limit:
|
| 110 |
+
todo = todo[: args.limit]
|
| 111 |
+
print(f"[questions] {len(done)} pages already covered, {len(todo)} to go", flush=True)
|
| 112 |
+
if not todo:
|
| 113 |
+
return 0
|
| 114 |
+
|
| 115 |
+
written = failed_batches = 0
|
| 116 |
+
with QUESTIONS_PATH.open("a") as out:
|
| 117 |
+
for at in range(0, len(todo), args.batch):
|
| 118 |
+
batch = todo[at : at + args.batch]
|
| 119 |
+
try:
|
| 120 |
+
raw = _raw_completion(batch_prompt(batch), system=SYSTEM, timeout=120.0)
|
| 121 |
+
except GenerationError as exc:
|
| 122 |
+
print(f"[questions] batch at {at} failed: {exc}", flush=True)
|
| 123 |
+
failed_batches += 1
|
| 124 |
+
if failed_batches >= 5:
|
| 125 |
+
print("[questions] 5 failed batches — provider looks down, stopping",
|
| 126 |
+
flush=True)
|
| 127 |
+
break
|
| 128 |
+
continue
|
| 129 |
+
sets = parse_questions(raw, len(batch))
|
| 130 |
+
if not sets:
|
| 131 |
+
print(f"[questions] batch at {at}: unparseable reply, skipped", flush=True)
|
| 132 |
+
failed_batches += 1
|
| 133 |
+
continue
|
| 134 |
+
for i, qs in sorted(sets.items()):
|
| 135 |
+
out.write(json.dumps({"url": batch[i]["url"], "questions": qs},
|
| 136 |
+
ensure_ascii=False) + "\n")
|
| 137 |
+
out.flush() # checkpoint: kill/rate-limit here loses nothing
|
| 138 |
+
written += len(sets)
|
| 139 |
+
print(f"[questions] {at + len(batch)}/{len(todo)} pages seen, "
|
| 140 |
+
f"{written} question sets written", flush=True)
|
| 141 |
+
time.sleep(args.sleep)
|
| 142 |
+
|
| 143 |
+
print(f"[questions] done: {written} new question sets → {QUESTIONS_PATH}", flush=True)
|
| 144 |
+
# partial success is success (resumable); total failure is loud
|
| 145 |
+
return 0 if written else 1
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
sys.exit(main())
|
|
@@ -99,6 +99,40 @@ def test_gloss_stamp_changes_the_recipe_with_gloss_content(monkeypatch, tmp_path
|
|
| 99 |
assert first not in ("none", embed._gloss_stamp())
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
def test_iter_corpus_units_walks_snapshot(tmp_path):
|
| 103 |
save_page("https://docs.pytorch.org/docs/stable/optim.html", "core", HTML, tmp_path)
|
| 104 |
units = list(iter_corpus_units(tmp_path))
|
|
|
|
| 99 |
assert first not in ("none", embed._gloss_stamp())
|
| 100 |
|
| 101 |
|
| 102 |
+
def test_indexed_text_folds_in_the_pages_hypothetical_questions(monkeypatch):
|
| 103 |
+
# QuOTE-style: the question phrasing itself joins both channels, so a
|
| 104 |
+
# descriptive query matches question→question instead of question→signature
|
| 105 |
+
from index.embed import indexed_text
|
| 106 |
+
|
| 107 |
+
url = "https://docs.pytorch.org/docs/stable/generated/torch.nn.Linear.html"
|
| 108 |
+
questions = [
|
| 109 |
+
"What's the standard fully-connected layer that applies a weight matrix and bias?",
|
| 110 |
+
"How do I add a dense layer to my network?",
|
| 111 |
+
]
|
| 112 |
+
monkeypatch.setattr("index.embed.load_glosses", lambda: {})
|
| 113 |
+
monkeypatch.setattr("index.embed.load_questions", lambda: {url: questions})
|
| 114 |
+
unit = {"url": url, "heading_path": ["Linear"], "content": "in_features (int)"}
|
| 115 |
+
text = indexed_text(unit)
|
| 116 |
+
for q in questions:
|
| 117 |
+
assert q in text
|
| 118 |
+
# questions sit between the symbol block and the body, like the gloss
|
| 119 |
+
assert text.index("torch.nn.Linear") < text.index(questions[0]) < text.index("in_features")
|
| 120 |
+
# a page with no questions is unchanged
|
| 121 |
+
assert questions[0] not in indexed_text({**unit, "url": url.replace("Linear", "GELU")})
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_questions_stamp_changes_the_recipe_with_question_content(monkeypatch, tmp_path):
|
| 125 |
+
# same contract as the gloss stamp: new/changed questions force the re-embed
|
| 126 |
+
from index import embed
|
| 127 |
+
|
| 128 |
+
monkeypatch.setattr(embed, "QUESTIONS_PATH", tmp_path / "questions.jsonl")
|
| 129 |
+
assert embed._questions_stamp() == "none"
|
| 130 |
+
embed.QUESTIONS_PATH.write_text('{"url": "u", "questions": ["q"]}\n')
|
| 131 |
+
first = embed._questions_stamp()
|
| 132 |
+
embed.QUESTIONS_PATH.write_text('{"url": "u", "questions": ["other"]}\n')
|
| 133 |
+
assert first not in ("none", embed._questions_stamp())
|
| 134 |
+
|
| 135 |
+
|
| 136 |
def test_iter_corpus_units_walks_snapshot(tmp_path):
|
| 137 |
save_page("https://docs.pytorch.org/docs/stable/optim.html", "core", HTML, tmp_path)
|
| 138 |
units = list(iter_corpus_units(tmp_path))
|
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Question generation: the pure parts — prompt shape, reply parsing, resume."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
from scripts.generate_glosses import existing_urls_of
|
| 6 |
+
from scripts.generate_questions import (
|
| 7 |
+
QUESTION_MAX_CHARS,
|
| 8 |
+
QUESTIONS_PER_PAGE,
|
| 9 |
+
batch_prompt,
|
| 10 |
+
parse_questions,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _page(url, title="T", excerpt="Applies a linear transformation."):
|
| 15 |
+
return {"url": url, "title": title, "excerpt": excerpt}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_batch_prompt_numbers_pages_and_uses_symbol():
|
| 19 |
+
prompt = batch_prompt(
|
| 20 |
+
[
|
| 21 |
+
_page("https://docs.pytorch.org/docs/stable/generated/torch.nn.Linear.html"),
|
| 22 |
+
_page("https://docs.pytorch.org/docs/stable/amp.html", title="AMP page"),
|
| 23 |
+
]
|
| 24 |
+
)
|
| 25 |
+
assert "### 0" in prompt and "### 1" in prompt
|
| 26 |
+
assert "torch.nn.Linear" in prompt # symbol derived from the url
|
| 27 |
+
assert "AMP page" in prompt # no symbol in the url → the title stands in
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_parse_questions_plain_json():
|
| 31 |
+
raw = json.dumps(
|
| 32 |
+
[
|
| 33 |
+
{"i": 0, "questions": ["How do I add a fully-connected layer?", " spaced "]},
|
| 34 |
+
{"i": 1, "questions": ["What splits a dataset randomly?"]},
|
| 35 |
+
]
|
| 36 |
+
)
|
| 37 |
+
out = parse_questions(raw, 2)
|
| 38 |
+
assert out[0][0] == "How do I add a fully-connected layer?"
|
| 39 |
+
assert out[0][1] == "spaced"
|
| 40 |
+
assert out[1] == ["What splits a dataset randomly?"]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_parse_questions_survives_fences_and_prose():
|
| 44 |
+
raw = 'Sure!\n```json\n[{"i": 0, "questions": ["q1"]}]\n```\nDone.'
|
| 45 |
+
assert parse_questions(raw, 1) == {0: ["q1"]}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_parse_questions_drops_malformed_items():
|
| 49 |
+
raw = json.dumps(
|
| 50 |
+
[
|
| 51 |
+
{"i": 5, "questions": ["out of range"]}, # index beyond the batch
|
| 52 |
+
{"i": 0, "questions": []}, # empty list → dropped
|
| 53 |
+
{"i": 1, "questions": ["ok", 7, ""]}, # non-strings/blanks filtered
|
| 54 |
+
"not a dict",
|
| 55 |
+
]
|
| 56 |
+
)
|
| 57 |
+
assert parse_questions(raw, 2) == {1: ["ok"]}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_parse_questions_caps_count_and_length():
|
| 61 |
+
many = [f"question {i}?" for i in range(QUESTIONS_PER_PAGE + 4)]
|
| 62 |
+
long = "x" * (QUESTION_MAX_CHARS + 50)
|
| 63 |
+
out = parse_questions(json.dumps([{"i": 0, "questions": many + [long]}]), 1)
|
| 64 |
+
assert len(out[0]) == QUESTIONS_PER_PAGE
|
| 65 |
+
assert all(len(q) <= QUESTION_MAX_CHARS for q in out[0])
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_parse_questions_non_json_is_empty():
|
| 69 |
+
assert parse_questions("the pages look great", 1) == {}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_existing_urls_of_resumes_from_the_output_file(tmp_path):
|
| 73 |
+
path = tmp_path / "questions.jsonl"
|
| 74 |
+
assert existing_urls_of(path) == set() # no file yet → nothing covered
|
| 75 |
+
path.write_text(
|
| 76 |
+
'{"url": "https://a", "questions": ["q"]}\n\n{"url": "https://b", "questions": ["q"]}\n'
|
| 77 |
+
)
|
| 78 |
+
assert existing_urls_of(path) == {"https://a", "https://b"}
|