nz-nz commited on
Commit
058157a
·
verified ·
1 Parent(s): 6736c51

Sync from GitHub via hub-sync

Browse files
Files changed (4) hide show
  1. content_pipeline.py +103 -0
  2. server.py +26 -11
  3. test_grade_answer.py +7 -0
  4. test_image_pdf.py +113 -0
content_pipeline.py CHANGED
@@ -108,6 +108,109 @@ def generate_deck(text: str, n: int = 12) -> list[Card]:
108
  return _dedupe(cards)[:n]
109
 
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  # ---- Internals (Frank implements) ------------------------------------------
112
 
113
  def _normalize(text: str) -> str:
 
108
  return _dedupe(cards)[:n]
109
 
110
 
111
+ # ---- Image-only / scanned PDFs (multimodal model) --------------------------
112
+ # When a PDF has no extractable text layer (scanned/photographed), there's
113
+ # nothing to chunk — so render the pages to images and let the multimodal model
114
+ # (MiniCPM-V) read them directly. Additive to the text path above (unchanged);
115
+ # only meaningful with a vision model (RECALL_MODEL=v46, RECALL_STUB=0).
116
+
117
+ MAX_PDF_IMAGE_PAGES = int(os.getenv("RECALL_MAX_PDF_PAGES", "8"))
118
+
119
+
120
+ def is_image_only_pdf(file) -> bool:
121
+ """True if `file` is a PDF whose pages have no selectable text (scanned/
122
+ image-only) — the case extract_text() rejects and this module renders
123
+ instead. False for non-PDFs / unreadable files (let extract_text surface the
124
+ real error)."""
125
+ path = getattr(file, "name", file)
126
+ if not str(path).lower().endswith(".pdf") or not os.path.isfile(path):
127
+ return False
128
+ try:
129
+ from pypdf import PdfReader
130
+ reader = PdfReader(path)
131
+ text = "".join((page.extract_text() or "") for page in reader.pages)
132
+ except Exception:
133
+ return False
134
+ return not _normalize(text)
135
+
136
+
137
+ def render_pdf_images(file, max_pages: int = MAX_PDF_IMAGE_PAGES) -> list:
138
+ """Render up to `max_pages` PDF pages to RGB PIL.Images for the vision model.
139
+ Returns [] for a non-PDF. Requires PyMuPDF + Pillow (real-model deps)."""
140
+ path = getattr(file, "name", file)
141
+ if not str(path).lower().endswith(".pdf"):
142
+ return []
143
+ import io
144
+
145
+ import fitz # PyMuPDF
146
+ from PIL import Image
147
+
148
+ images = []
149
+ with fitz.open(path) as doc:
150
+ for page in list(doc)[:max_pages]:
151
+ pix = page.get_pixmap(dpi=150)
152
+ images.append(Image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB"))
153
+ return images
154
+
155
+
156
+ def generate_deck_from_images(images: list, n: int = 12) -> list[Card]:
157
+ """Generate question cards from PDF page images via the multimodal model —
158
+ the image-only/scanned-PDF counterpart to generate_deck(text). Always returns
159
+ valid Cards (bad model output is skipped, never crashes).
160
+
161
+ NEEDS GPU VERIFICATION: the real path needs a vision model + GPU and can't be
162
+ exercised locally. Stub returns the canned demo deck so the flow still runs.
163
+ """
164
+ if llm.STUB:
165
+ return generate_deck("") # canned demo deck — no model/GPU
166
+ if not images:
167
+ return []
168
+ messages = [
169
+ {"role": "system", "content": (
170
+ "You are a quiz generator. You are given the page images of a study "
171
+ "document. Produce 3 to 5 quiz questions that test different aspects of "
172
+ "the material at varying difficulty levels.\n\n"
173
+ "GROUNDING RULES (critical):\n"
174
+ "- Every question AND its answer must be answerable using ONLY what is "
175
+ "visible in the page images.\n"
176
+ "- Do NOT introduce facts, names, numbers, or examples not in the images.\n"
177
+ "- If the pages are too thin for a question, write fewer rather than "
178
+ "inventing content.\n\n"
179
+ "Return ONLY a JSON array. Each element must have exactly these keys:\n"
180
+ " question — the question text (clear, specific, one sentence)\n"
181
+ " answer — a concise reference answer (1-3 sentences)\n"
182
+ " topic — a short tag naming the concept (e.g. 'Cell Biology')\n"
183
+ " difficulty — integer: 1 (recall), 2 (application), 3 (synthesis)\n\n"
184
+ "No prose, no markdown fences, no text outside the JSON array."
185
+ )},
186
+ {"role": "user", "content": list(images) + [
187
+ "Generate quiz questions from these document page images."]},
188
+ ]
189
+ # Same defensive strict-JSON + repair pass as the text path; images ride along
190
+ # in the message content and llm.chat() routes them to the vision model.
191
+ data = llm.chat_json(messages, max_tokens=900, retries=1)
192
+ if not isinstance(data, list):
193
+ return []
194
+ out: list[Card] = []
195
+ for item in data:
196
+ if not isinstance(item, dict):
197
+ continue
198
+ try:
199
+ difficulty = max(1, min(3, int(item.get("difficulty") or 1)))
200
+ except (ValueError, TypeError):
201
+ difficulty = 1
202
+ card = new_card(
203
+ question=str(item.get("question", "")).strip(),
204
+ answer=str(item.get("answer", "")).strip(),
205
+ topic=str(item.get("topic", "General")).strip() or "General",
206
+ source_chunk="[scanned PDF page]",
207
+ difficulty=difficulty,
208
+ )
209
+ if validate_card(card):
210
+ out.append(card)
211
+ return _dedupe(out)[:n]
212
+
213
+
214
  # ---- Internals (Frank implements) ------------------------------------------
215
 
216
  def _normalize(text: str) -> str:
server.py CHANGED
@@ -197,6 +197,9 @@ async def api_generate(
197
  "notes or pick a PDF, and I'll build your deck.")
198
 
199
  source = ""
 
 
 
200
  if text and text.strip():
201
  source = text.strip()[:MAX_TEXT_CHARS] # cap to bound chunking work
202
  if len(source) < 40:
@@ -228,22 +231,34 @@ async def api_generate(
228
  # Extraction (PDF parsing) is blocking — keep it off the event loop.
229
  source = await run_in_threadpool(cp.extract_text, tmp_path)
230
  except cp.ExtractionError as e:
231
- return JSONResponse({"error": str(e)}, status_code=422)
 
 
 
 
 
 
 
 
 
232
  finally:
233
  os.unlink(tmp_path)
234
  else:
235
  return JSONResponse({"error": need_more}, status_code=400)
236
 
237
- try:
238
- # Deck generation hits the model — run it in a worker thread so a slow
239
- # call doesn't block every other request on the event loop.
240
- deck = await run_in_threadpool(cp.generate_deck, source)
241
- except Exception as e: # noqa: BLE001 — surface as friendly copy, never crash
242
- return JSONResponse(
243
- {"error": f"Couldn't build a deck from that ({type(e).__name__}). "
244
- "Try different material."},
245
- status_code=422,
246
- )
 
 
 
247
  if not deck:
248
  return JSONResponse(
249
  {"error": "Couldn't generate questions from that. Try different material."},
 
197
  "notes or pick a PDF, and I'll build your deck.")
198
 
199
  source = ""
200
+ # Set when a scanned/image-only PDF is turned into a deck by the vision model
201
+ # (no text to chunk); `None` means "fall through to the text path below".
202
+ deck_from_images = None
203
  if text and text.strip():
204
  source = text.strip()[:MAX_TEXT_CHARS] # cap to bound chunking work
205
  if len(source) < 40:
 
231
  # Extraction (PDF parsing) is blocking — keep it off the event loop.
232
  source = await run_in_threadpool(cp.extract_text, tmp_path)
233
  except cp.ExtractionError as e:
234
+ # A scanned/image-only PDF has no text to chunk — render its pages and
235
+ # let the multimodal model read them directly. Fall back to the
236
+ # original error if it's not image-only or no pages render.
237
+ if await run_in_threadpool(cp.is_image_only_pdf, tmp_path):
238
+ images = await run_in_threadpool(cp.render_pdf_images, tmp_path)
239
+ if images:
240
+ deck_from_images = await run_in_threadpool(
241
+ cp.generate_deck_from_images, images)
242
+ if deck_from_images is None:
243
+ return JSONResponse({"error": str(e)}, status_code=422)
244
  finally:
245
  os.unlink(tmp_path)
246
  else:
247
  return JSONResponse({"error": need_more}, status_code=400)
248
 
249
+ if deck_from_images is not None:
250
+ deck = deck_from_images
251
+ else:
252
+ try:
253
+ # Deck generation hits the model — run it in a worker thread so a slow
254
+ # call doesn't block every other request on the event loop.
255
+ deck = await run_in_threadpool(cp.generate_deck, source)
256
+ except Exception as e: # noqa: BLE001 — surface as friendly copy, never crash
257
+ return JSONResponse(
258
+ {"error": f"Couldn't build a deck from that ({type(e).__name__}). "
259
+ "Try different material."},
260
+ status_code=422,
261
+ )
262
  if not deck:
263
  return JSONResponse(
264
  {"error": "Couldn't generate questions from that. Try different material."},
test_grade_answer.py CHANGED
@@ -15,6 +15,13 @@ import llm
15
  import learning_engine as le
16
  from schema import new_card
17
 
 
 
 
 
 
 
 
18
 
19
  def _card():
20
  return new_card(
 
15
  import learning_engine as le
16
  from schema import new_card
17
 
18
+ # `llm.STUB` is read once at import. Under the full pytest run another test file
19
+ # imports `llm` first (with stub on), so the env flip above wouldn't take — and
20
+ # grade_answer would silently use the heuristic instead of the patched chat.
21
+ # Pin it off here so these tests are robust to import order. (Downstream test
22
+ # files either reload llm or don't depend on STUB.)
23
+ llm.STUB = False
24
+
25
 
26
  def _card():
27
  return new_card(
test_image_pdf.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Image-only / scanned-PDF path: detect no-text PDFs, render pages to images, and
3
+ build a deck from them. The render/detect plumbing is testable without a GPU;
4
+ the model call is exercised only in stub mode here (real vision inference is
5
+ covered by the deploy, not locally). Skips when the real-model image deps
6
+ (PyMuPDF + Pillow) aren't installed — they live in requirements-model.txt.
7
+
8
+ RECALL_STUB=1 python3 -m pytest test_image_pdf.py
9
+ """
10
+ import io
11
+ import os
12
+ import tempfile
13
+
14
+ import pytest
15
+
16
+ os.environ.setdefault("RECALL_STUB", "1")
17
+
18
+ fitz = pytest.importorskip("fitz", reason="needs PyMuPDF (real-model dep)")
19
+ PIL_Image = pytest.importorskip("PIL.Image", reason="needs Pillow (real-model dep)")
20
+ from PIL import Image, ImageDraw # noqa: E402
21
+
22
+ import content_pipeline as cp # noqa: E402
23
+
24
+
25
+ def _make_scanned_pdf(pages: int = 2) -> str:
26
+ """A PDF with no text layer — each page is a rasterized image."""
27
+ fd, path = tempfile.mkstemp(suffix=".pdf")
28
+ os.close(fd)
29
+ doc = fitz.open()
30
+ for i in range(pages):
31
+ img = Image.new("RGB", (600, 800), "white")
32
+ ImageDraw.Draw(img).text((40, 40), f"Scanned page {i + 1}", fill="black")
33
+ buf = io.BytesIO()
34
+ img.save(buf, format="PNG")
35
+ page = doc.new_page(width=600, height=800)
36
+ page.insert_image(fitz.Rect(0, 0, 600, 800), stream=buf.getvalue())
37
+ doc.save(path)
38
+ doc.close()
39
+ return path
40
+
41
+
42
+ def _make_text_pdf() -> str:
43
+ fd, path = tempfile.mkstemp(suffix=".pdf")
44
+ os.close(fd)
45
+ doc = fitz.open()
46
+ doc.new_page().insert_text((72, 72), "Selectable text about photosynthesis.")
47
+ doc.save(path)
48
+ doc.close()
49
+ return path
50
+
51
+
52
+ def test_is_image_only_pdf_detects_scanned():
53
+ path = _make_scanned_pdf()
54
+ try:
55
+ assert cp.is_image_only_pdf(path) is True
56
+ finally:
57
+ os.unlink(path)
58
+
59
+
60
+ def test_text_pdf_is_not_image_only():
61
+ path = _make_text_pdf()
62
+ try:
63
+ assert cp.is_image_only_pdf(path) is False
64
+ finally:
65
+ os.unlink(path)
66
+
67
+
68
+ def test_non_pdf_is_not_image_only_and_renders_nothing():
69
+ assert cp.is_image_only_pdf("notes.txt") is False
70
+ assert cp.render_pdf_images("notes.txt") == []
71
+
72
+
73
+ def test_render_pdf_images_returns_pages():
74
+ path = _make_scanned_pdf(pages=3)
75
+ try:
76
+ images = cp.render_pdf_images(path)
77
+ assert len(images) == 3
78
+ assert all(isinstance(im, Image.Image) for im in images)
79
+ finally:
80
+ os.unlink(path)
81
+
82
+
83
+ def test_render_pdf_images_respects_max_pages():
84
+ path = _make_scanned_pdf(pages=5)
85
+ try:
86
+ assert len(cp.render_pdf_images(path, max_pages=2)) == 2
87
+ finally:
88
+ os.unlink(path)
89
+
90
+
91
+ def test_generate_deck_from_images_stub():
92
+ # STUB returns the canned demo deck regardless of the images passed. Pin
93
+ # llm.STUB explicitly (and restore) so this is robust to other test files'
94
+ # import-order / STUB mutations rather than relying on ambient state.
95
+ import llm
96
+ prev = llm.STUB
97
+ llm.STUB = True
98
+ try:
99
+ deck = cp.generate_deck_from_images([object(), object()])
100
+ finally:
101
+ llm.STUB = prev
102
+ assert isinstance(deck, list) and len(deck) >= 1
103
+ assert all(c.get("question") and c.get("answer") for c in deck)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ test_is_image_only_pdf_detects_scanned()
108
+ test_text_pdf_is_not_image_only()
109
+ test_non_pdf_is_not_image_only_and_renders_nothing()
110
+ test_render_pdf_images_returns_pages()
111
+ test_render_pdf_images_respects_max_pages()
112
+ test_generate_deck_from_images_stub()
113
+ print("All image-PDF tests passed.")