R14-ASQ-E1: qualitative evidence structured-output recovery

#53
Files changed (1) hide show
  1. core/qualitative_engine.py +124 -23
core/qualitative_engine.py CHANGED
@@ -272,37 +272,138 @@ Rules:
272
  {schema}
273
  """
274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  def resolve_qualitative_data(self, topic, keywords, raw_interviews=""):
276
  has_user_data = bool(raw_interviews and len(raw_interviews) > 100)
277
- prompt = self._build_request(topic, keywords, raw_interviews)
278
  # R3-Free engine code is headless. Streamlit rendering belongs to app.py;
279
  # this method only produces frozen evidence + metadata.
280
- response = "".join(
281
- str(chunk or "")
282
- for chunk in self.agent.stream_deepseek(
283
- "You are a rigorous qualitative research analyst. Evidence fidelity has priority over narrative completeness.",
284
- prompt,
285
- model_role="reasoning",
286
- thinking=True,
287
- allow_fallback=False,
288
- )
289
  )
290
 
291
- response = re.sub(
292
- r"<think>.*?</think>",
293
- "",
294
- response,
295
- flags=re.DOTALL,
296
- ).strip()
297
- data = self.agent._extract_json_from_text(response)
298
-
299
- if not isinstance(data, dict):
300
- raise RuntimeError("Qualitative evidence packet could not be parsed safely.")
301
-
302
  facts = data.get("study_facts") or {}
303
  evidence = data.get("evidence") or data.get("quotes") or []
304
- if not isinstance(facts, dict) or not isinstance(evidence, list):
305
- raise RuntimeError("Qualitative evidence packet has an invalid schema.")
306
 
307
  data_sources = facts.get("data_sources") or ["Not specified"]
308
  if not isinstance(data_sources, list):
 
272
  {schema}
273
  """
274
 
275
+ def _qualitative_packet_validation_error(self, data, *, has_user_data):
276
+ """Return a bounded local validation reason, or an empty string when safe.
277
+
278
+ This pre-freeze validator mirrors the existing fatal packet gates so a
279
+ malformed/incomplete model response can be regenerated once before any
280
+ study fact or quotation becomes authoritative. It never repairs JSON or
281
+ mutates model output.
282
+ """
283
+ if not isinstance(data, dict):
284
+ return "packet_not_json_object"
285
+
286
+ facts = data.get("study_facts") or {}
287
+ evidence = data.get("evidence") or data.get("quotes") or []
288
+ if not isinstance(facts, dict) or not isinstance(evidence, list):
289
+ return "invalid_packet_schema"
290
+
291
+ for key in ("participant_count", "interview_count"):
292
+ value = facts.get(key)
293
+ if value is not None and (not isinstance(value, int) or value <= 0):
294
+ return f"invalid_{key}"
295
+
296
+ seen_quotes = set()
297
+ theme_keys = set()
298
+ retained = 0
299
+ has_deviant = False
300
+ for item in evidence[:15]:
301
+ if not isinstance(item, dict):
302
+ continue
303
+ quote = self._clean_cell(item.get("quote"), "").replace('"', "'")
304
+ normalized_quote = re.sub(r"\s+", " ", quote).strip().casefold()
305
+ if not quote or len(quote.split()) < 4 or normalized_quote in seen_quotes:
306
+ continue
307
+ seen_quotes.add(normalized_quote)
308
+ retained += 1
309
+ theme = self._clean_cell(item.get("theme"), "Emergent pattern")
310
+ theme_keys.add(re.sub(r"\s+", " ", theme).strip().casefold())
311
+ if str(item.get("evidence_role") or "typical").strip().lower() == "deviant":
312
+ has_deviant = True
313
+
314
+ validation = APP_CONFIG.get("manuscript_validation", {})
315
+ minimum_themes = int(validation.get("minimum_qualitative_themes", 3))
316
+ minimum_quotes = int(validation.get("minimum_qualitative_quotes", 8))
317
+ if len(theme_keys) < minimum_themes or retained < minimum_quotes:
318
+ return f"incomplete_packet_themes_{len(theme_keys)}_quotes_{retained}"
319
+ if not has_user_data and not has_deviant:
320
+ return "generated_packet_missing_deviant_evidence"
321
+ return ""
322
+
323
+ def _generate_qualitative_packet(self, topic, keywords, raw_interviews, *, has_user_data):
324
+ """Generate and validate one evidence packet with one bounded fresh retry."""
325
+ system_prompt = (
326
+ "You are a rigorous qualitative research analyst. "
327
+ "Evidence fidelity has priority over narrative completeness."
328
+ )
329
+ base_prompt = self._build_request(topic, keywords, raw_interviews)
330
+ last_reason = "packet_not_json_object"
331
+
332
+ for local_attempt in range(2):
333
+ # API/auth/balance/cancellation/transport failures remain owned by
334
+ # the model client and are deliberately not converted into local
335
+ # content retries here. Only a completed but locally invalid packet
336
+ # reaches the second attempt.
337
+ if local_attempt == 0:
338
+ response = "".join(
339
+ str(chunk or "")
340
+ for chunk in self.agent.stream_deepseek(
341
+ system_prompt,
342
+ base_prompt,
343
+ model_role="reasoning",
344
+ thinking=True,
345
+ allow_fallback=False,
346
+ )
347
+ )
348
+ else:
349
+ retry_prompt = base_prompt + (
350
+ "\n\nLOCAL VALIDATION RETRY: The previous completed response failed local "
351
+ "JSON/schema/content validation. Generate a fresh complete qualitative evidence "
352
+ "packet from the ORIGINAL inputs above. Do not repair, continue, quote, or rely on "
353
+ "the previous response. Return exactly one complete JSON object matching the required "
354
+ "schema, with no Markdown or explanatory prose. Preserve every evidence-fidelity rule "
355
+ "from the original request, including verbatim quotation requirements when user material "
356
+ "was supplied."
357
+ )
358
+ response = self.agent.call_deepseek(
359
+ system_prompt,
360
+ retry_prompt,
361
+ max_tokens=8000,
362
+ temp=0.0,
363
+ model_role="reasoning",
364
+ thinking=False,
365
+ allow_fallback=False,
366
+ response_format={"type": "json_object"},
367
+ )
368
+
369
+ response = re.sub(
370
+ r"<think>.*?</think>",
371
+ "",
372
+ str(response or ""),
373
+ flags=re.DOTALL,
374
+ ).strip()
375
+ data = self.agent._extract_json_from_text(response)
376
+ last_reason = self._qualitative_packet_validation_error(
377
+ data,
378
+ has_user_data=has_user_data,
379
+ )
380
+ if not last_reason:
381
+ return data
382
+ if local_attempt == 0:
383
+ logging.warning(
384
+ "Qualitative evidence structured-output local retry | attempt=1/2 | reason=%s",
385
+ last_reason,
386
+ )
387
+
388
+ if last_reason == "packet_not_json_object":
389
+ raise RuntimeError("Qualitative evidence packet could not be parsed safely.")
390
+ if last_reason == "invalid_packet_schema":
391
+ raise RuntimeError("Qualitative evidence packet has an invalid schema.")
392
+ raise RuntimeError(
393
+ "Qualitative evidence packet failed local validation after one bounded regeneration: "
394
+ f"{last_reason}."
395
+ )
396
+
397
  def resolve_qualitative_data(self, topic, keywords, raw_interviews=""):
398
  has_user_data = bool(raw_interviews and len(raw_interviews) > 100)
 
399
  # R3-Free engine code is headless. Streamlit rendering belongs to app.py;
400
  # this method only produces frozen evidence + metadata.
401
+ data = self._generate_qualitative_packet(
402
+ topic, keywords, raw_interviews, has_user_data=has_user_data
 
 
 
 
 
 
 
403
  )
404
 
 
 
 
 
 
 
 
 
 
 
 
405
  facts = data.get("study_facts") or {}
406
  evidence = data.get("evidence") or data.get("quotes") or []
 
 
407
 
408
  data_sources = facts.get("data_sources") or ["Not specified"]
409
  if not isinstance(data_sources, list):