ming commited on
Commit
d25a17f
·
1 Parent(s): 29ed661

Remove outlines library and all related code

Browse files

- Remove outlines dependency from requirements.txt (NumPy compatibility issue)
- Remove outlines import validation from Dockerfile
- Remove /stream-json endpoint from V4 API (outlines-based)
- Remove all outlines-related code from structured_summarizer.py
- Remove outlines import tests
- Delete test_v4_live.py (outlines integration tests)
- Remove all stream-json endpoint tests from test_v4_api.py

This resolves the Hugging Face build failure caused by outlines==0.0.44
incompatibility with newer NumPy versions (ModuleNotFoundError: numpy.lib.function_base).

V4 API now only supports /stream and /stream-ndjson endpoints using
NDJSON patch-based structured summarization (no external dependencies).

Dockerfile CHANGED
@@ -29,9 +29,7 @@ COPY requirements.txt .
29
 
30
  # Install Python dependencies
31
  RUN pip install --no-cache-dir --upgrade pip && \
32
- pip install --no-cache-dir -r requirements.txt && \
33
- python -c "import outlines; print(f'✅ Outlines installed: {outlines.__version__ if hasattr(outlines, \"__version__\") else \"version unknown\"}')" || \
34
- (echo "❌ Outlines installation failed!" && pip list | grep -i outline && exit 1)
35
 
36
  # Copy application code
37
  COPY app/ ./app/
 
29
 
30
  # Install Python dependencies
31
  RUN pip install --no-cache-dir --upgrade pip && \
32
+ pip install --no-cache-dir -r requirements.txt
 
 
33
 
34
  # Copy application code
35
  COPY app/ ./app/
app/api/v4/structured_summary.py CHANGED
@@ -303,99 +303,3 @@ async def _stream_generator_ndjson(text: str, payload, metadata: dict, request_i
303
  logger.info(
304
  f"[{request_id}] V4 NDJSON text mode completed in {total_latency_ms:.2f}ms"
305
  )
306
-
307
-
308
- @router.post("/scrape-and-summarize/stream-json")
309
- async def scrape_and_summarize_stream_json(
310
- request: Request, payload: StructuredSummaryRequest
311
- ):
312
- """
313
- V4: Full JSON structured summarization with streaming using Outlines.
314
-
315
- This endpoint streams a single JSON object token-by-token via SSE.
316
- The final concatenated response is a valid JSON matching StructuredSummary.
317
- """
318
- request_id = getattr(request.state, "request_id", "unknown")
319
-
320
- # Determine input mode (same logic as other endpoints)
321
- if payload.url:
322
- logger.info(f"[{request_id}] V4 JSON URL mode: {payload.url[:80]}...")
323
-
324
- scrape_start = time.time()
325
- try:
326
- article_data = await article_scraper_service.scrape_article(
327
- url=payload.url, use_cache=payload.use_cache
328
- )
329
- except Exception as e:
330
- logger.error(f"[{request_id}] Scraping failed: {e}")
331
- raise HTTPException(
332
- status_code=502, detail=f"Failed to scrape article: {str(e)}"
333
- )
334
-
335
- scrape_latency_ms = (time.time() - scrape_start) * 1000
336
- logger.info(
337
- f"[{request_id}] Scraped in {scrape_latency_ms:.2f}ms, "
338
- f"extracted {len(article_data['text'])} chars"
339
- )
340
-
341
- if len(article_data["text"]) < 100:
342
- raise HTTPException(
343
- status_code=422,
344
- detail="Insufficient content extracted from URL. "
345
- "Article may be behind paywall or site may block scrapers.",
346
- )
347
-
348
- text_to_summarize = article_data["text"]
349
- metadata = {
350
- "input_type": "url",
351
- "url": payload.url,
352
- "title": article_data.get("title"),
353
- "author": article_data.get("author"),
354
- "date": article_data.get("date"),
355
- "site_name": article_data.get("site_name"),
356
- "scrape_method": article_data.get("method", "static"),
357
- "scrape_latency_ms": scrape_latency_ms,
358
- "extracted_text_length": len(article_data["text"]),
359
- "style": payload.style.value,
360
- }
361
- else:
362
- logger.info(f"[{request_id}] V4 JSON text mode: {len(payload.text)} chars")
363
-
364
- text_to_summarize = payload.text
365
- metadata = {
366
- "input_type": "text",
367
- "text_length": len(payload.text),
368
- "style": payload.style.value,
369
- }
370
-
371
- async def _stream_generator_json():
372
- # Optional: send metadata as first event
373
- if payload.include_metadata:
374
- metadata_event = {"type": "metadata", "data": metadata}
375
- yield f"data: {json.dumps(metadata_event)}\n\n"
376
-
377
- # Now stream the JSON tokens from the service
378
- try:
379
- async for (
380
- token
381
- ) in structured_summarizer_service.summarize_structured_stream_json(
382
- text=text_to_summarize,
383
- style=payload.style.value,
384
- ):
385
- # Each token is a raw JSON fragment; just forward it
386
- yield f"data: {token}\n\n"
387
- except Exception as e:
388
- logger.error(f"[{request_id}] V4 JSON streaming failed: {e}")
389
- error_event = {"type": "error", "error": str(e), "done": True}
390
- yield f"data: {json.dumps(error_event)}\n\n"
391
-
392
- return StreamingResponse(
393
- _stream_generator_json(),
394
- media_type="text/event-stream",
395
- headers={
396
- "Cache-Control": "no-cache",
397
- "Connection": "keep-alive",
398
- "X-Accel-Buffering": "no",
399
- "X-Request-ID": request_id,
400
- },
401
- )
 
303
  logger.info(
304
  f"[{request_id}] V4 NDJSON text mode completed in {total_latency_ms:.2f}ms"
305
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/structured_summarizer.py CHANGED
@@ -54,50 +54,6 @@ except ImportError:
54
  # Import Pydantic for schema definition
55
  from pydantic import BaseModel
56
 
57
- # Try to import Outlines for JSON schema enforcement
58
- OUTLINES_AVAILABLE = False
59
- outlines_models = None
60
- outlines_generate = None
61
-
62
- try:
63
- import outlines
64
-
65
- # Check what's available in outlines module
66
- available_attrs = [attr for attr in dir(outlines) if not attr.startswith("_")]
67
- logger.info(f"Outlines module attributes: {available_attrs}")
68
-
69
- # Try to import models
70
- try:
71
- from outlines import models as outlines_models
72
- except ImportError:
73
- logger.warning("Could not import outlines.models")
74
- raise
75
-
76
- # Try to import generate module (for outlines.generate.json)
77
- try:
78
- from outlines import generate as outlines_generate
79
-
80
- logger.info("✅ Found outlines.generate module")
81
- except ImportError as e:
82
- logger.warning(f"Could not import outlines.generate: {e}")
83
- outlines_generate = None
84
-
85
- if outlines_generate is None:
86
- raise ImportError(
87
- f"Could not import outlines.generate. Available in outlines: {available_attrs[:10]}..."
88
- )
89
-
90
- OUTLINES_AVAILABLE = True
91
- logger.info("✅ Outlines library imported successfully")
92
- except ImportError as e:
93
- logger.warning(
94
- f"Outlines library not available: {e}. V4 JSON streaming endpoints will be disabled."
95
- )
96
- except Exception as e:
97
- logger.warning(
98
- f"Error importing Outlines library: {e}. V4 JSON streaming endpoints will be disabled."
99
- )
100
-
101
 
102
  class StructuredSummary(BaseModel):
103
  """Pydantic schema for structured summary output."""
@@ -117,7 +73,6 @@ class StructuredSummarizer:
117
  """Initialize the Qwen model and tokenizer with GPU/INT4 when possible."""
118
  self.tokenizer: AutoTokenizer | None = None
119
  self.model: AutoModelForCausalLM | None = None
120
- self.outlines_model = None # Outlines wrapper over the HF model
121
 
122
  if not TRANSFORMERS_AVAILABLE:
123
  logger.warning("⚠️ Transformers not available - V4 endpoints will not work")
@@ -234,22 +189,6 @@ class StructuredSummarizer:
234
  logger.info(f" Model device: {next(self.model.parameters()).device}")
235
  logger.info(f" Torch dtype: {next(self.model.parameters()).dtype}")
236
 
237
- # Wrap the HF model + tokenizer in an Outlines Transformers model
238
- if OUTLINES_AVAILABLE:
239
- try:
240
- self.outlines_model = outlines_models.Transformers(
241
- self.model, self.tokenizer
242
- )
243
- logger.info("✅ Outlines model wrapper initialized for V4")
244
- except Exception as e:
245
- logger.error(f"❌ Failed to initialize Outlines wrapper: {e}")
246
- self.outlines_model = None
247
- else:
248
- logger.warning(
249
- "⚠️ Outlines not available - V4 JSON streaming endpoints will be disabled"
250
- )
251
- self.outlines_model = None
252
-
253
  except Exception as e:
254
  logger.error(f"❌ Failed to initialize V4 model: {e}")
255
  logger.error(f"Model ID: {settings.v4_model_id}")
@@ -272,28 +211,6 @@ class StructuredSummarizer:
272
  except Exception as e:
273
  logger.error(f"❌ V4 model warmup failed: {e}")
274
 
275
- # Also warm up Outlines JSON generation
276
- if (
277
- OUTLINES_AVAILABLE
278
- and self.outlines_model is not None
279
- and outlines_generate is not None
280
- ):
281
- try:
282
- # Use outlines.generate.json(model, schema) pattern
283
- json_generator = outlines_generate.json(
284
- self.outlines_model, StructuredSummary
285
- )
286
-
287
- # Try to call it with a simple prompt
288
- result = json_generator("Warmup text for Outlines structured summary.")
289
- # Consume the generator if it's a generator
290
- if hasattr(result, "__iter__") and not isinstance(result, str):
291
- _ = list(result)[:1] # Just consume first item for warmup
292
-
293
- logger.info("✅ V4 Outlines JSON warmup successful")
294
- except Exception as e:
295
- logger.warning(f"⚠️ V4 Outlines JSON warmup failed: {e}")
296
-
297
  def _generate_test(self, prompt: str):
298
  """Test generation for warmup."""
299
  inputs = self.tokenizer(prompt, return_tensors="pt")
@@ -969,110 +886,6 @@ Rules:
969
  "error": "V4 NDJSON summarization failed. See server logs.",
970
  }
971
 
972
- async def summarize_structured_stream_json(
973
- self,
974
- text: str,
975
- style: str = "executive",
976
- ) -> AsyncGenerator[str, None]:
977
- """
978
- Stream a single JSON object (StructuredSummary) token-by-token
979
- using Outlines constrained decoding.
980
-
981
- Yields:
982
- Raw string tokens that, when concatenated, form a valid JSON object.
983
- """
984
- if not self.outlines_model:
985
- logger.error("❌ Outlines model not available for V4")
986
- # Provide detailed error information
987
- if not OUTLINES_AVAILABLE:
988
- error_msg = (
989
- "Outlines library not installed. Please install outlines>=0.0.34."
990
- )
991
- elif not self.model or not self.tokenizer:
992
- error_msg = (
993
- "Base V4 model not loaded. Outlines wrapper cannot be created."
994
- )
995
- else:
996
- error_msg = "Outlines model wrapper initialization failed. Check server logs for details."
997
-
998
- error_obj = {
999
- "error": "V4 Outlines model not available",
1000
- "detail": error_msg,
1001
- }
1002
- yield json.dumps(error_obj)
1003
- return
1004
-
1005
- # Map existing styles to a short instruction
1006
- style_prompts = {
1007
- "skimmer": "Summarize concisely using only hard facts and data.",
1008
- "executive": "Summarize for a CEO. Focus on key facts and business impact. Be concise.",
1009
- "eli5": "Explain in very simple language with minimal jargon.",
1010
- }
1011
- style_instruction = style_prompts.get(style, style_prompts["executive"])
1012
-
1013
- # Truncate text to prevent token overflow (reuse your existing max_chars idea)
1014
- max_chars = 10000
1015
- if len(text) > max_chars:
1016
- logger.warning(
1017
- f"Truncating input text from {len(text)} to {max_chars} chars for V4 JSON streaming."
1018
- )
1019
- text = text[:max_chars]
1020
-
1021
- # Build a compact prompt; Outlines will handle the schema, so no huge system prompt needed
1022
- prompt = (
1023
- f"{style_instruction}\n\n"
1024
- f"Produce a JSON object that matches this schema exactly:\n"
1025
- f"- title: short headline\n"
1026
- f"- main_summary: 2-4 sentences\n"
1027
- f"- key_points: 3-5 concise bullet points\n"
1028
- f"- category: 1-2 word topic label (e.g. 'Crime', 'Tech')\n"
1029
- f"- sentiment: one of ['positive', 'negative', 'neutral']\n"
1030
- f"- read_time_min: integer reading time in minutes\n\n"
1031
- f"ARTICLE:\n{text}"
1032
- )
1033
-
1034
- logger.info(f"V4 Outlines JSON streaming: {len(text)} chars, style={style}")
1035
-
1036
- try:
1037
- # Check if Outlines is available
1038
- if not OUTLINES_AVAILABLE or outlines_generate is None:
1039
- error_obj = {
1040
- "error": "Outlines library not available. Please install outlines>=0.0.34."
1041
- }
1042
- yield json.dumps(error_obj)
1043
- return
1044
-
1045
- start_time = time.time()
1046
-
1047
- # Create an Outlines generator bound to the StructuredSummary schema
1048
- # Modern Outlines API: outlines.generate.json(model, schema)
1049
- json_generator = outlines_generate.json(
1050
- self.outlines_model, StructuredSummary
1051
- )
1052
-
1053
- # Call the generator with the prompt to get streaming tokens
1054
- # The generator returns an iterable of string tokens
1055
- token_iter = json_generator(prompt)
1056
-
1057
- # Stream tokens; each token is a string fragment of the final JSON object
1058
- for token in token_iter:
1059
- # Each `token` is a raw string fragment; just pass it through
1060
- if token:
1061
- yield token
1062
- # Let the event loop breathe
1063
- await asyncio.sleep(0)
1064
-
1065
- latency_ms = (time.time() - start_time) * 1000.0
1066
- logger.info(
1067
- f"✅ V4 Outlines JSON streaming completed in {latency_ms:.2f}ms"
1068
- )
1069
-
1070
- except Exception as e:
1071
- logger.exception("❌ V4 Outlines JSON streaming failed")
1072
- # Yield a minimal JSON error object as final output
1073
- error_obj = {"error": "V4 JSON streaming failed", "detail": str(e)}
1074
- yield json.dumps(error_obj)
1075
-
1076
 
1077
  # Global service instance
1078
  structured_summarizer_service = StructuredSummarizer()
 
54
  # Import Pydantic for schema definition
55
  from pydantic import BaseModel
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  class StructuredSummary(BaseModel):
59
  """Pydantic schema for structured summary output."""
 
73
  """Initialize the Qwen model and tokenizer with GPU/INT4 when possible."""
74
  self.tokenizer: AutoTokenizer | None = None
75
  self.model: AutoModelForCausalLM | None = None
 
76
 
77
  if not TRANSFORMERS_AVAILABLE:
78
  logger.warning("⚠️ Transformers not available - V4 endpoints will not work")
 
189
  logger.info(f" Model device: {next(self.model.parameters()).device}")
190
  logger.info(f" Torch dtype: {next(self.model.parameters()).dtype}")
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  except Exception as e:
193
  logger.error(f"❌ Failed to initialize V4 model: {e}")
194
  logger.error(f"Model ID: {settings.v4_model_id}")
 
211
  except Exception as e:
212
  logger.error(f"❌ V4 model warmup failed: {e}")
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  def _generate_test(self, prompt: str):
215
  """Test generation for warmup."""
216
  inputs = self.tokenizer(prompt, return_tensors="pt")
 
886
  "error": "V4 NDJSON summarization failed. See server logs.",
887
  }
888
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889
 
890
  # Global service instance
891
  structured_summarizer_service = StructuredSummarizer()
requirements.txt CHANGED
@@ -20,7 +20,6 @@ accelerate>=0.33.0,<1.0.0 # Required for GPU quantization (V4)
20
  bitsandbytes>=0.44.0 # 4-bit NF4 quantization for GPU (V4)
21
  einops>=0.6.0,<1.0.0 # Required for model architecture (V4)
22
  scipy>=1.10.0,<2.0.0 # Often needed for unquantized models (V4)
23
- outlines==0.0.44 # JSON schema enforcement for V4 structured summarization (pinned version tested and working)
24
 
25
  # Testing
26
  pytest>=7.0.0,<8.0.0
 
20
  bitsandbytes>=0.44.0 # 4-bit NF4 quantization for GPU (V4)
21
  einops>=0.6.0,<1.0.0 # Required for model architecture (V4)
22
  scipy>=1.10.0,<2.0.0 # Often needed for unquantized models (V4)
 
23
 
24
  # Testing
25
  pytest>=7.0.0,<8.0.0
tests/test_imports.py CHANGED
@@ -70,15 +70,6 @@ class TestExternalDependencies:
70
  except ImportError:
71
  pytest.skip("torch not available (optional)")
72
 
73
- def test_outlines_import(self):
74
- """Test outlines can be imported."""
75
- try:
76
- import outlines # noqa: F401
77
-
78
- assert True
79
- except ImportError:
80
- pytest.skip("outlines not available (optional)")
81
-
82
  def test_trafilatura_import(self):
83
  """Test trafilatura can be imported."""
84
  try:
 
70
  except ImportError:
71
  pytest.skip("torch not available (optional)")
72
 
 
 
 
 
 
 
 
 
 
73
  def test_trafilatura_import(self):
74
  """Test trafilatura can be imported."""
75
  try:
tests/test_v4_api.py CHANGED
@@ -353,424 +353,3 @@ async def test_v4_sse_headers(client: TestClient):
353
  assert response.headers["cache-control"] == "no-cache"
354
  assert response.headers["connection"] == "keep-alive"
355
  assert "x-request-id" in response.headers
356
-
357
-
358
- # ============================================================================
359
- # Tests for /api/v4/scrape-and-summarize/stream-json endpoint
360
- # ============================================================================
361
-
362
-
363
- def test_v4_stream_json_url_mode_success(client: TestClient):
364
- """Test stream-json endpoint with URL input (successful scraping and JSON streaming)."""
365
- with patch(
366
- "app.services.article_scraper.article_scraper_service.scrape_article"
367
- ) as mock_scrape:
368
- mock_scrape.return_value = {
369
- "text": "Artificial intelligence is transforming modern technology. "
370
- "Machine learning algorithms are becoming more sophisticated. "
371
- "Deep learning models can now process vast amounts of data efficiently."
372
- * 10,
373
- "title": "AI Revolution 2024",
374
- "author": "Dr. Jane Smith",
375
- "date": "2024-11-30",
376
- "site_name": "Tech Insights",
377
- "url": "https://techinsights.com/ai-2024",
378
- "method": "static",
379
- "scrape_time_ms": 425.8,
380
- }
381
-
382
- # Mock JSON streaming from Outlines
383
- async def mock_json_stream(*args, **kwargs):
384
- # Yield raw JSON token fragments (simulating Outlines output)
385
- yield '{"title": "'
386
- yield "AI Revolution"
387
- yield '", "main_summary": "'
388
- yield "Artificial intelligence is rapidly evolving"
389
- yield '", "key_points": ['
390
- yield '"AI is transforming technology"'
391
- yield ', "ML algorithms are improving"'
392
- yield ', "Deep learning processes data efficiently"'
393
- yield '], "category": "'
394
- yield "Technology"
395
- yield '", "sentiment": "'
396
- yield "positive"
397
- yield '", "read_time_min": '
398
- yield "3"
399
- yield "}"
400
-
401
- with patch(
402
- "app.services.structured_summarizer.structured_summarizer_service.summarize_structured_stream_json",
403
- side_effect=mock_json_stream,
404
- ):
405
- response = client.post(
406
- "/api/v4/scrape-and-summarize/stream-json",
407
- json={
408
- "url": "https://techinsights.com/ai-2024",
409
- "style": "executive",
410
- "max_tokens": 512,
411
- "include_metadata": True,
412
- },
413
- )
414
-
415
- assert response.status_code == 200
416
- assert (
417
- response.headers["content-type"] == "text/event-stream; charset=utf-8"
418
- )
419
-
420
- # Parse SSE stream
421
- events = []
422
- for line in response.text.split("\n"):
423
- if line.startswith("data: "):
424
- events.append(line[6:]) # Keep raw data
425
-
426
- # First event should be metadata JSON
427
- metadata_event = json.loads(events[0])
428
- assert metadata_event["type"] == "metadata"
429
- assert metadata_event["data"]["input_type"] == "url"
430
- assert metadata_event["data"]["url"] == "https://techinsights.com/ai-2024"
431
- assert metadata_event["data"]["title"] == "AI Revolution 2024"
432
- assert metadata_event["data"]["author"] == "Dr. Jane Smith"
433
- assert metadata_event["data"]["style"] == "executive"
434
- assert "scrape_latency_ms" in metadata_event["data"]
435
-
436
- # Rest should be raw JSON tokens
437
- json_tokens = events[1:]
438
- complete_json = "".join(json_tokens)
439
-
440
- # Verify it's valid JSON
441
- parsed_json = json.loads(complete_json)
442
- assert parsed_json["title"] == "AI Revolution"
443
- assert "AI is transforming technology" in parsed_json["key_points"]
444
- assert parsed_json["category"] == "Technology"
445
- assert parsed_json["sentiment"] == "positive"
446
- assert parsed_json["read_time_min"] == 3
447
-
448
-
449
- def test_v4_stream_json_text_mode_success(client: TestClient):
450
- """Test stream-json endpoint with direct text input (no scraping)."""
451
- test_text = (
452
- "Climate change poses significant challenges to global ecosystems. "
453
- "Rising temperatures affect weather patterns worldwide. "
454
- "Scientists emphasize the need for immediate action."
455
- )
456
-
457
- async def mock_json_stream(*args, **kwargs):
458
- yield '{"title": "Climate Change Impact", '
459
- yield '"main_summary": "Climate change affects global ecosystems", '
460
- yield '"key_points": ["Rising temperatures", "Weather patterns"], '
461
- yield '"category": "Environment", '
462
- yield '"sentiment": "neutral", '
463
- yield '"read_time_min": 1}'
464
-
465
- with patch(
466
- "app.services.structured_summarizer.structured_summarizer_service.summarize_structured_stream_json",
467
- side_effect=mock_json_stream,
468
- ):
469
- response = client.post(
470
- "/api/v4/scrape-and-summarize/stream-json",
471
- json={
472
- "text": test_text,
473
- "style": "skimmer",
474
- "max_tokens": 256,
475
- "include_metadata": True,
476
- },
477
- )
478
-
479
- assert response.status_code == 200
480
-
481
- # Parse events
482
- events = []
483
- for line in response.text.split("\n"):
484
- if line.startswith("data: "):
485
- events.append(line[6:])
486
-
487
- # Check metadata for text mode
488
- metadata_event = json.loads(events[0])
489
- assert metadata_event["type"] == "metadata"
490
- assert metadata_event["data"]["input_type"] == "text"
491
- assert metadata_event["data"]["text_length"] == len(test_text)
492
- assert metadata_event["data"]["style"] == "skimmer"
493
- assert "url" not in metadata_event["data"] # URL mode fields not present
494
-
495
- # Verify JSON output
496
- complete_json = "".join(events[1:])
497
- parsed_json = json.loads(complete_json)
498
- assert parsed_json["title"] == "Climate Change Impact"
499
- assert parsed_json["category"] == "Environment"
500
-
501
-
502
- def test_v4_stream_json_no_metadata(client: TestClient):
503
- """Test stream-json endpoint with include_metadata=false."""
504
-
505
- async def mock_json_stream(*args, **kwargs):
506
- yield '{"title": "Test", '
507
- yield '"main_summary": "Summary", '
508
- yield '"key_points": ["A"], '
509
- yield '"category": "Test", '
510
- yield '"sentiment": "neutral", '
511
- yield '"read_time_min": 1}'
512
-
513
- with patch(
514
- "app.services.structured_summarizer.structured_summarizer_service.summarize_structured_stream_json",
515
- side_effect=mock_json_stream,
516
- ):
517
- response = client.post(
518
- "/api/v4/scrape-and-summarize/stream-json",
519
- json={
520
- "text": "Test article content for summary generation with enough characters to pass validation."
521
- * 2,
522
- "style": "eli5",
523
- "include_metadata": False,
524
- },
525
- )
526
-
527
- assert response.status_code == 200
528
-
529
- # Parse events
530
- events = []
531
- for line in response.text.split("\n"):
532
- if line.startswith("data: "):
533
- events.append(line[6:])
534
-
535
- # Should NOT have metadata event (check first event)
536
- # Metadata events are complete JSON with "type": "metadata"
537
- if events and events[0]:
538
- try:
539
- first_event = json.loads(events[0])
540
- assert first_event.get("type") != "metadata", (
541
- "Metadata should not be included"
542
- )
543
- except json.JSONDecodeError:
544
- # First event is not complete JSON, so it's raw tokens (good!)
545
- pass
546
-
547
- # All events should be JSON tokens that combine to valid JSON
548
- complete_json = "".join(events)
549
- parsed_json = json.loads(complete_json)
550
- assert parsed_json["title"] == "Test"
551
-
552
-
553
- def test_v4_stream_json_different_styles(client: TestClient):
554
- """Test stream-json endpoint with different summarization styles."""
555
- styles_to_test = ["skimmer", "executive", "eli5"]
556
-
557
- for style in styles_to_test:
558
- # Capture loop variable in closure
559
- def make_mock_stream(style_name: str):
560
- async def mock_json_stream(*args, **kwargs):
561
- yield f'{{"title": "{style_name.upper()}", '
562
- yield '"main_summary": "Test", '
563
- yield '"key_points": ["A"], '
564
- yield '"category": "Test", '
565
- yield '"sentiment": "positive", '
566
- yield '"read_time_min": 1}'
567
-
568
- return mock_json_stream
569
-
570
- with patch(
571
- "app.services.structured_summarizer.structured_summarizer_service.summarize_structured_stream_json",
572
- side_effect=make_mock_stream(style),
573
- ):
574
- response = client.post(
575
- "/api/v4/scrape-and-summarize/stream-json",
576
- json={
577
- "text": "Test content for different styles with sufficient character count to pass validation requirements."
578
- * 2,
579
- "style": style,
580
- "include_metadata": False,
581
- },
582
- )
583
-
584
- assert response.status_code == 200, f"Failed for style: {style}"
585
-
586
-
587
- def test_v4_stream_json_custom_max_tokens(client: TestClient):
588
- """Test stream-json endpoint with custom max_tokens parameter."""
589
-
590
- async def mock_json_stream(text, style, max_tokens=None):
591
- # Verify max_tokens is passed through
592
- assert max_tokens == 1536
593
- yield '{"title": "Custom Tokens", '
594
- yield '"main_summary": "Test", '
595
- yield '"key_points": ["A"], '
596
- yield '"category": "Test", '
597
- yield '"sentiment": "neutral", '
598
- yield '"read_time_min": 1}'
599
-
600
- with patch(
601
- "app.services.structured_summarizer.structured_summarizer_service.summarize_structured_stream_json",
602
- side_effect=mock_json_stream,
603
- ):
604
- response = client.post(
605
- "/api/v4/scrape-and-summarize/stream-json",
606
- json={
607
- "text": "Test content with custom max tokens that meets minimum character requirements."
608
- * 3,
609
- "style": "executive",
610
- "max_tokens": 1536,
611
- "include_metadata": False,
612
- },
613
- )
614
-
615
- assert response.status_code == 200
616
-
617
-
618
- def test_v4_stream_json_scraping_failure(client: TestClient):
619
- """Test stream-json endpoint when article scraping fails."""
620
- with patch(
621
- "app.services.article_scraper.article_scraper_service.scrape_article"
622
- ) as mock_scrape:
623
- mock_scrape.side_effect = Exception("Network timeout")
624
-
625
- response = client.post(
626
- "/api/v4/scrape-and-summarize/stream-json",
627
- json={
628
- "url": "https://example.com/unreachable",
629
- "style": "executive",
630
- },
631
- )
632
-
633
- assert response.status_code == 502
634
- assert "detail" in response.json()
635
- assert "scrape" in response.json()["detail"].lower()
636
-
637
-
638
- def test_v4_stream_json_content_too_short(client: TestClient):
639
- """Test stream-json endpoint when scraped content is too short."""
640
- with patch(
641
- "app.services.article_scraper.article_scraper_service.scrape_article"
642
- ) as mock_scrape:
643
- mock_scrape.return_value = {
644
- "text": "Too short", # Less than 100 characters
645
- "title": "Short Article",
646
- "url": "https://example.com/short",
647
- "method": "static",
648
- "scrape_time_ms": 200.0,
649
- }
650
-
651
- response = client.post(
652
- "/api/v4/scrape-and-summarize/stream-json",
653
- json={
654
- "url": "https://example.com/short",
655
- "style": "executive",
656
- },
657
- )
658
-
659
- assert response.status_code == 422
660
- assert "detail" in response.json()
661
- assert "insufficient" in response.json()["detail"].lower()
662
-
663
-
664
- def test_v4_stream_json_ssrf_protection(client: TestClient):
665
- """Test stream-json endpoint blocks SSRF attempts."""
666
- ssrf_urls = [
667
- "http://localhost/admin",
668
- "http://127.0.0.1/secrets",
669
- "http://192.168.1.1/internal",
670
- "http://10.0.0.1/private",
671
- ]
672
-
673
- for url in ssrf_urls:
674
- response = client.post(
675
- "/api/v4/scrape-and-summarize/stream-json",
676
- json={
677
- "url": url,
678
- "style": "executive",
679
- },
680
- )
681
-
682
- assert response.status_code == 422, f"SSRF not blocked for: {url}"
683
- # FastAPI validation errors return detail array
684
- assert "detail" in response.json()
685
-
686
-
687
- def test_v4_stream_json_validation_errors(client: TestClient):
688
- """Test stream-json endpoint input validation."""
689
- # Missing both url and text
690
- response = client.post(
691
- "/api/v4/scrape-and-summarize/stream-json",
692
- json={"style": "executive"},
693
- )
694
- assert response.status_code == 422
695
-
696
- # Both url and text provided
697
- response = client.post(
698
- "/api/v4/scrape-and-summarize/stream-json",
699
- json={
700
- "url": "https://example.com",
701
- "text": "Some text",
702
- "style": "executive",
703
- },
704
- )
705
- assert response.status_code == 422
706
-
707
- # Text too short
708
- response = client.post(
709
- "/api/v4/scrape-and-summarize/stream-json",
710
- json={
711
- "text": "Short",
712
- "style": "executive",
713
- },
714
- )
715
- assert response.status_code == 422
716
-
717
- # Invalid style
718
- response = client.post(
719
- "/api/v4/scrape-and-summarize/stream-json",
720
- json={
721
- "text": "Valid length text for testing validation" * 5,
722
- "style": "invalid_style",
723
- },
724
- )
725
- assert response.status_code == 422
726
-
727
-
728
- def test_v4_stream_json_response_headers(client: TestClient):
729
- """Test stream-json endpoint returns correct SSE headers."""
730
-
731
- async def mock_json_stream(*args, **kwargs):
732
- yield '{"title": "Test", "main_summary": "Test", "key_points": [], '
733
- yield '"category": "Test", "sentiment": "neutral", "read_time_min": 1}'
734
-
735
- with patch(
736
- "app.services.structured_summarizer.structured_summarizer_service.summarize_structured_stream_json",
737
- side_effect=mock_json_stream,
738
- ):
739
- response = client.post(
740
- "/api/v4/scrape-and-summarize/stream-json",
741
- json={
742
- "text": "Test content for header validation." * 10,
743
- "style": "executive",
744
- },
745
- )
746
-
747
- # Verify SSE headers
748
- assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
749
- assert response.headers["cache-control"] == "no-cache"
750
- assert response.headers["connection"] == "keep-alive"
751
- assert response.headers["x-accel-buffering"] == "no"
752
- assert "x-request-id" in response.headers
753
-
754
-
755
- def test_v4_stream_json_request_id_tracking(client: TestClient):
756
- """Test stream-json endpoint respects X-Request-ID header."""
757
- custom_request_id = "test-request-12345"
758
-
759
- async def mock_json_stream(*args, **kwargs):
760
- yield '{"title": "Test", "main_summary": "Test", "key_points": [], '
761
- yield '"category": "Test", "sentiment": "neutral", "read_time_min": 1}'
762
-
763
- with patch(
764
- "app.services.structured_summarizer.structured_summarizer_service.summarize_structured_stream_json",
765
- side_effect=mock_json_stream,
766
- ):
767
- response = client.post(
768
- "/api/v4/scrape-and-summarize/stream-json",
769
- json={
770
- "text": "Test content for request ID tracking." * 10,
771
- "style": "executive",
772
- },
773
- headers={"X-Request-ID": custom_request_id},
774
- )
775
-
776
- assert response.headers["x-request-id"] == custom_request_id
 
353
  assert response.headers["cache-control"] == "no-cache"
354
  assert response.headers["connection"] == "keep-alive"
355
  assert "x-request-id" in response.headers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_v4_live.py DELETED
@@ -1,267 +0,0 @@
1
- """
2
- Live integration tests for V4 Outlines functionality.
3
-
4
- These tests actually exercise the Outlines library (not mocked) to verify
5
- it's working correctly. They require the Outlines library to be installed
6
- and will fail if there are API compatibility issues.
7
-
8
- Run with: pytest tests/test_v4_live.py -v
9
- """
10
-
11
- import json
12
-
13
- import pytest
14
- from pydantic import ValidationError
15
-
16
- # Mark all tests in this file as integration tests
17
- pytestmark = pytest.mark.integration
18
-
19
-
20
- def test_outlines_library_imports():
21
- """Test that Outlines library can be imported successfully."""
22
- try:
23
- import outlines
24
- from outlines import generate as outlines_generate
25
- from outlines import models as outlines_models
26
-
27
- # Verify key components exist
28
- assert outlines is not None
29
- assert outlines_models is not None
30
- assert outlines_generate is not None
31
- assert hasattr(outlines_generate, "json"), (
32
- "outlines.generate should have 'json' method"
33
- )
34
-
35
- print("✅ Outlines library imported successfully")
36
- except ImportError as e:
37
- pytest.fail(f"Failed to import Outlines library: {e}")
38
-
39
-
40
- def test_outlines_availability_flag():
41
- """Test that the OUTLINES_AVAILABLE flag is set correctly."""
42
- from app.services.structured_summarizer import OUTLINES_AVAILABLE
43
-
44
- assert OUTLINES_AVAILABLE is True, (
45
- "OUTLINES_AVAILABLE should be True if Outlines is installed. "
46
- "Check app/services/structured_summarizer.py import section."
47
- )
48
-
49
-
50
- @pytest.mark.asyncio
51
- async def test_structured_summarizer_initialization():
52
- """Test that StructuredSummarizer initializes with Outlines wrapper."""
53
- from app.services.structured_summarizer import structured_summarizer_service
54
-
55
- # Check that the service was initialized
56
- assert structured_summarizer_service is not None
57
-
58
- # Check that Outlines model wrapper was created
59
- assert hasattr(structured_summarizer_service, "outlines_model"), (
60
- "StructuredSummarizer should have 'outlines_model' attribute"
61
- )
62
-
63
- assert structured_summarizer_service.outlines_model is not None, (
64
- "Outlines model wrapper should be initialized. "
65
- "Check StructuredSummarizer.__init__() for errors."
66
- )
67
-
68
- print("✅ StructuredSummarizer initialized with Outlines wrapper")
69
-
70
-
71
- @pytest.mark.asyncio
72
- async def test_outlines_json_streaming_basic():
73
- """
74
- Test that Outlines can generate structured JSON stream.
75
-
76
- This is a REAL test - no mocking. It will fail if:
77
- - Outlines library has API compatibility issues
78
- - The model wrapper isn't working
79
- - The JSON schema binding fails
80
- - The streaming doesn't produce valid JSON
81
- """
82
- from app.api.v4.schemas import StructuredSummary, SummarizationStyle
83
- from app.services.structured_summarizer import structured_summarizer_service
84
-
85
- # Use a simple test text
86
- test_text = (
87
- "Artificial intelligence is transforming the technology industry. "
88
- "Machine learning models are becoming more powerful and accessible. "
89
- "Companies are investing billions in AI research and development."
90
- )
91
-
92
- # Call the actual Outlines-based streaming method
93
- json_tokens = []
94
- async for token in structured_summarizer_service.summarize_structured_stream_json(
95
- text=test_text, style=SummarizationStyle.EXECUTIVE, max_tokens=256
96
- ):
97
- json_tokens.append(token)
98
-
99
- # Combine all tokens into complete JSON string
100
- complete_json = "".join(json_tokens)
101
-
102
- print(f"\n📝 Generated JSON ({len(complete_json)} chars):")
103
- print(complete_json)
104
-
105
- # Verify it's valid JSON
106
- try:
107
- parsed_json = json.loads(complete_json)
108
- except json.JSONDecodeError as e:
109
- pytest.fail(
110
- f"Outlines generated invalid JSON: {e}\n\nGenerated content:\n{complete_json}"
111
- )
112
-
113
- # Verify it matches the StructuredSummary schema
114
- try:
115
- structured_summary = StructuredSummary(**parsed_json)
116
-
117
- # Verify required fields are present and non-empty
118
- assert structured_summary.title, "title should not be empty"
119
- assert structured_summary.main_summary, "main_summary should not be empty"
120
- assert structured_summary.key_points, "key_points should not be empty"
121
- assert len(structured_summary.key_points) > 0, (
122
- "key_points should have at least one item"
123
- )
124
- assert structured_summary.category, "category should not be empty"
125
- assert structured_summary.sentiment in ["positive", "negative", "neutral"], (
126
- f"sentiment should be valid enum value, got: {structured_summary.sentiment}"
127
- )
128
- assert structured_summary.read_time_min > 0, "read_time_min should be positive"
129
-
130
- print("✅ Outlines generated valid StructuredSummary:")
131
- print(f" Title: {structured_summary.title}")
132
- print(f" Summary: {structured_summary.main_summary[:100]}...")
133
- print(f" Key Points: {len(structured_summary.key_points)} items")
134
- print(f" Category: {structured_summary.category}")
135
- print(f" Sentiment: {structured_summary.sentiment}")
136
- print(f" Read Time: {structured_summary.read_time_min} min")
137
-
138
- except ValidationError as e:
139
- pytest.fail(
140
- f"Outlines generated JSON doesn't match StructuredSummary schema: {e}\n\nGenerated JSON:\n{complete_json}"
141
- )
142
-
143
-
144
- @pytest.mark.asyncio
145
- async def test_outlines_json_streaming_different_styles():
146
- """Test that Outlines works with different summarization styles."""
147
- from app.api.v4.schemas import StructuredSummary, SummarizationStyle
148
- from app.services.structured_summarizer import structured_summarizer_service
149
-
150
- test_text = "Climate change is affecting global weather patterns. Scientists warn of rising temperatures."
151
-
152
- styles_to_test = [
153
- SummarizationStyle.SKIMMER,
154
- SummarizationStyle.EXECUTIVE,
155
- SummarizationStyle.ELI5,
156
- ]
157
-
158
- for style in styles_to_test:
159
- json_tokens = []
160
- async for (
161
- token
162
- ) in structured_summarizer_service.summarize_structured_stream_json(
163
- text=test_text, style=style, max_tokens=128
164
- ):
165
- json_tokens.append(token)
166
-
167
- complete_json = "".join(json_tokens)
168
-
169
- try:
170
- parsed_json = json.loads(complete_json)
171
- StructuredSummary(**parsed_json)
172
- print(f"✅ Style {style.value}: Generated valid summary")
173
- except (json.JSONDecodeError, ValidationError) as e:
174
- pytest.fail(
175
- f"Failed to generate valid summary for style {style.value}: {e}"
176
- )
177
-
178
-
179
- @pytest.mark.asyncio
180
- async def test_outlines_with_longer_text():
181
- """Test Outlines with longer text that triggers truncation."""
182
- from app.api.v4.schemas import StructuredSummary, SummarizationStyle
183
- from app.services.structured_summarizer import structured_summarizer_service
184
-
185
- # Create a longer text (will be truncated to 10000 chars)
186
- test_text = (
187
- "The history of artificial intelligence dates back to the 1950s. "
188
- "Alan Turing proposed the Turing Test as a measure of machine intelligence. "
189
- "In the decades that followed, AI research went through cycles of optimism and setbacks. "
190
- ) * 100 # Repeat to make it long
191
-
192
- json_tokens = []
193
- async for token in structured_summarizer_service.summarize_structured_stream_json(
194
- text=test_text, style=SummarizationStyle.EXECUTIVE, max_tokens=256
195
- ):
196
- json_tokens.append(token)
197
-
198
- complete_json = "".join(json_tokens)
199
-
200
- try:
201
- parsed_json = json.loads(complete_json)
202
- StructuredSummary(**parsed_json)
203
- print(f"✅ Long text: Generated valid summary from {len(test_text)} chars")
204
- except (json.JSONDecodeError, ValidationError) as e:
205
- pytest.fail(f"Failed to generate valid summary for long text: {e}")
206
-
207
-
208
- @pytest.mark.asyncio
209
- async def test_outlines_error_handling_when_model_unavailable():
210
- """Test that proper error JSON is returned if Outlines model is unavailable."""
211
- from app.api.v4.schemas import SummarizationStyle
212
- from app.services.structured_summarizer import StructuredSummarizer
213
-
214
- # Create a StructuredSummarizer instance without initializing the model
215
- # This simulates the case where Outlines is unavailable
216
- fake_summarizer = StructuredSummarizer.__new__(StructuredSummarizer)
217
- fake_summarizer.outlines_model = None # Simulate unavailable Outlines
218
- fake_summarizer.model = None
219
- fake_summarizer.tokenizer = None
220
-
221
- json_tokens = []
222
- async for token in fake_summarizer.summarize_structured_stream_json(
223
- text="Test text", style=SummarizationStyle.EXECUTIVE, max_tokens=128
224
- ):
225
- json_tokens.append(token)
226
-
227
- complete_json = "".join(json_tokens)
228
-
229
- # Should return error JSON
230
- try:
231
- parsed_json = json.loads(complete_json)
232
- assert "error" in parsed_json, "Error response should contain 'error' field"
233
- print(f"✅ Error handling: {parsed_json['error']}")
234
- except json.JSONDecodeError as e:
235
- pytest.fail(f"Error response is not valid JSON: {e}")
236
-
237
-
238
- if __name__ == "__main__":
239
- # Allow running this file directly for quick testing
240
- import asyncio
241
-
242
- print("Running Outlines integration tests...\n")
243
-
244
- # Run synchronous tests
245
- print("1. Testing Outlines imports...")
246
- test_outlines_library_imports()
247
-
248
- print("\n2. Testing Outlines availability flag...")
249
- test_outlines_availability_flag()
250
-
251
- # Run async tests
252
- print("\n3. Testing StructuredSummarizer initialization...")
253
- asyncio.run(test_structured_summarizer_initialization())
254
-
255
- print("\n4. Testing Outlines JSON streaming (basic)...")
256
- asyncio.run(test_outlines_json_streaming_basic())
257
-
258
- print("\n5. Testing different summarization styles...")
259
- asyncio.run(test_outlines_json_streaming_different_styles())
260
-
261
- print("\n6. Testing with longer text...")
262
- asyncio.run(test_outlines_with_longer_text())
263
-
264
- print("\n7. Testing error handling...")
265
- asyncio.run(test_outlines_error_handling_when_model_unavailable())
266
-
267
- print("\n✅ All Outlines integration tests passed!")