P0-R7-D-R1 mixed reference/research materials + parse-first gate

#147
Files changed (5) hide show
  1. app.py +313 -58
  2. config.py +31 -0
  3. core/advanced_method_registry.py +10 -1
  4. i18n.py +28 -0
  5. utils/file_parser.py +145 -54
app.py CHANGED
@@ -39,6 +39,7 @@ from core.advanced_method_registry import (
39
  reconcile_method_authority, resolve_generation_preflight,
40
  )
41
  from smartpaper_restore_bridge import edge_bridge
 
42
 
43
  st.set_page_config(
44
  page_title="Article Agent",
@@ -309,7 +310,8 @@ for key, default in {
309
  "sp_method_selector_ui": "",
310
  "sp_research_design_ui_snapshot": "",
311
  "sp_research_intent_snapshot": "",
312
- "sp_material_document_use": "reference",
 
313
  }.items():
314
  if key not in st.session_state:
315
  st.session_state[key] = default
@@ -477,6 +479,7 @@ def _task_output_path(task: dict) -> Path | None:
477
 
478
 
479
  def _save_uploaded(items, inputs_dir: Path, prefix: str) -> list[str]:
 
480
  saved = []
481
  for index, item in enumerate(items or []):
482
  if item is None:
@@ -485,8 +488,15 @@ def _save_uploaded(items, inputs_dir: Path, prefix: str) -> list[str]:
485
  safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", original)[:140] or f"input_{index}"
486
  filename = f"{prefix}_{index:02d}_{safe_name}"
487
  path = inputs_dir / filename
488
- data = item.getvalue() if hasattr(item, "getvalue") else item.read()
489
- path.write_bytes(bytes(data or b""))
 
 
 
 
 
 
 
490
  saved.append(str(Path("inputs") / filename))
491
  try:
492
  item.seek(0)
@@ -495,6 +505,176 @@ def _save_uploaded(items, inputs_dir: Path, prefix: str) -> list[str]:
495
  return saved
496
 
497
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  def _uploaded_material_descriptors() -> list[dict[str, str]]:
499
  out: list[dict[str, str]] = []
500
  for item in st.session_state.get("up_research_materials") or []:
@@ -598,55 +778,70 @@ def _current_method_preflight(*, confirmed_method_family: str | None = None) ->
598
 
599
  def _classify_and_save_research_materials(
600
  method_family: str, inputs_dir: Path
601
- ) -> tuple[list[str], list[str], list[str], str]:
602
- """Map one visible uploader into legacy request buckets without changing engines."""
603
- uploads = list(st.session_state.get("up_research_materials") or [])
604
- if not uploads:
605
- return [], [], [], "none"
606
 
607
- structured = []
608
- text_materials = []
609
- documents = []
610
- for item in uploads:
611
- name = os.path.basename(str(getattr(item, "name", "") or ""))
612
- ext = Path(name).suffix.lower()
613
- if ext in {".csv", ".xls", ".xlsx"}:
614
- structured.append(item)
615
- elif ext == ".txt":
616
- text_materials.append(item)
617
- else:
618
- documents.append(item)
619
 
620
- decision = str(st.session_state.get("sp_material_document_use") or "reference").strip().lower()
621
- if decision not in {"reference", "research"}:
622
- decision = "reference"
623
 
624
- buckets = {"reference": [], "data": [], "interview": []}
625
- capability = method_capability(method_family)
626
- material_routing = dict(capability.get("material_routing") or {})
627
 
628
- def _route(items: list, route_key: str) -> None:
629
- bucket = str(material_routing.get(route_key) or "").strip().casefold()
630
- if bucket not in buckets:
631
- raise RuntimeError("method_material_routing_unsupported")
632
- buckets[bucket].extend(items)
633
-
634
- _route(structured, "structured")
635
- _route(text_materials, "text")
636
- _route(
637
- documents,
638
- "document_research" if decision == "research" else "document_reference",
639
  )
640
 
641
- reference_items = buckets["reference"]
642
- data_items = buckets["data"]
643
- interview_items = buckets["interview"]
644
-
645
- refs = _save_uploaded(reference_items, inputs_dir, "ref")
646
- data = _save_uploaded(data_items[:1], inputs_dir, "data")
647
- interviews = _save_uploaded(interview_items[:1], inputs_dir, "interview")
648
- return refs, data, interviews, decision
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
 
 
650
 
651
  def _persist_task_request(
652
  task: dict,
@@ -669,7 +864,7 @@ def _persist_task_request(
669
  if not expected_lane or expected_lane != str(mode or "").strip().lower():
670
  raise RuntimeError("method_family_policy_lane_mismatch")
671
 
672
- refs, data, interviews, material_use_decision = _classify_and_save_research_materials(
673
  method_family, inputs_dir
674
  )
675
 
@@ -681,6 +876,7 @@ def _persist_task_request(
681
  "method_resolver_version": str(method_resolver_version or METHOD_RESOLVER_VERSION),
682
  "derived_policy_lane": expected_lane,
683
  "material_use_decision": material_use_decision,
 
684
  "interface_language": lang_code,
685
  "target_lang": get_target_language(lang_code),
686
  "output_suffix": get_output_suffix(lang_code),
@@ -889,6 +1085,14 @@ def _process_pending_launch(lang_code: str) -> None:
889
  st.warning(tr(lang_code, "method_resolution_invalid"))
890
  return
891
 
 
 
 
 
 
 
 
 
892
  projection = get_product_ui_projection()
893
  selected_product_tier = (
894
  str(st.session_state.get("sp_product_tier") or "").strip().upper()
@@ -1586,18 +1790,39 @@ def render_workspace_controls(user):
1586
  )
1587
 
1588
  uploaded_materials = list(st.session_state.get("up_research_materials") or [])
1589
- has_document_material = any(
1590
- Path(str(getattr(item, "name", "") or "")).suffix.lower() in {".pdf", ".doc", ".docx"}
1591
- for item in uploaded_materials
1592
- )
1593
- if has_document_material and not active:
1594
- st.radio(
1595
- tr(lang_code, "material_document_use_label"),
1596
- ["reference", "research"],
1597
- format_func=lambda value: tr(lang_code, f"material_use_{value}"),
1598
- horizontal=True,
1599
- key="sp_material_document_use",
1600
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1601
 
1602
  if active:
1603
  active_family = _task_method_family(task)
@@ -1636,6 +1861,30 @@ def render_workspace_controls(user):
1636
  )
1637
  if active:
1638
  st.markdown(f"**{tr(lang_code, 'detected_method_label')}:** {method_label}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1639
  st.caption(tr(lang_code, "method_detection_explanation"))
1640
 
1641
  block_reason = str(preflight.get("block_reason") or "")
@@ -1740,6 +1989,7 @@ def render_workspace_controls(user):
1740
  st.rerun()
1741
  can_start = bool(
1742
  preflight.get("can_start_generation")
 
1743
  and product_ui_ready
1744
  and selected_remaining is not None
1745
  and selected_remaining > 0
@@ -1757,12 +2007,17 @@ def render_workspace_controls(user):
1757
  click_preflight, click_signature = _current_method_preflight(
1758
  confirmed_method_family=launch_family
1759
  )
 
 
 
1760
  if (
1761
  not launch_family
1762
  or not bool(click_preflight.get("can_start_generation"))
1763
  or str(click_preflight.get("method_family") or "").strip().casefold() != launch_family
1764
  ):
1765
  st.warning(tr(lang_code, "method_resolution_invalid"))
 
 
1766
  else:
1767
  resolved_mode = str(click_preflight.get("legacy_policy_lane") or "").strip().lower()
1768
  capability = dict(click_preflight.get("capability") or {})
 
39
  reconcile_method_authority, resolve_generation_preflight,
40
  )
41
  from smartpaper_restore_bridge import edge_bridge
42
+ from utils.file_parser import extract_file_content, validate_file_parseable
43
 
44
  st.set_page_config(
45
  page_title="Article Agent",
 
310
  "sp_method_selector_ui": "",
311
  "sp_research_design_ui_snapshot": "",
312
  "sp_research_intent_snapshot": "",
313
+ "sp_material_document_use": "reference", # legacy recovered-session compatibility only
314
+ "sp_material_parse_cache": {},
315
  }.items():
316
  if key not in st.session_state:
317
  st.session_state[key] = default
 
479
 
480
 
481
  def _save_uploaded(items, inputs_dir: Path, prefix: str) -> list[str]:
482
+ """Persist uploads without copying the entire file into one Python bytes object."""
483
  saved = []
484
  for index, item in enumerate(items or []):
485
  if item is None:
 
488
  safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", original)[:140] or f"input_{index}"
489
  filename = f"{prefix}_{index:02d}_{safe_name}"
490
  path = inputs_dir / filename
491
+ try:
492
+ item.seek(0)
493
+ except Exception:
494
+ pass
495
+ if hasattr(item, "read"):
496
+ with path.open("wb") as target:
497
+ shutil.copyfileobj(item, target, length=1024 * 1024)
498
+ else:
499
+ path.write_bytes(bytes(item or b""))
500
  saved.append(str(Path("inputs") / filename))
501
  try:
502
  item.seek(0)
 
505
  return saved
506
 
507
 
508
+ def _research_material_upload_policy() -> dict:
509
+ policy = dict(APP_CONFIG.get("research_material_upload_policy") or {})
510
+ reference_limit = int(policy.get("reference_file_limit") or 0)
511
+ research_limit = int(policy.get("research_material_file_limit") or 0)
512
+ if reference_limit <= 0 or research_limit <= 0:
513
+ raise RuntimeError("research_material_upload_policy_invalid")
514
+ return {
515
+ **policy,
516
+ "reference_file_limit": reference_limit,
517
+ "research_material_file_limit": research_limit,
518
+ "qualitative_min_combined_chars": max(
519
+ 1, int(policy.get("qualitative_min_combined_chars") or 1)
520
+ ),
521
+ "quantitative_bundle_max_chars": max(
522
+ 1000, int(policy.get("quantitative_bundle_max_chars") or 20000)
523
+ ),
524
+ "qualitative_bundle_max_chars": max(
525
+ 1000, int(policy.get("qualitative_bundle_max_chars") or APP_CONFIG.get("max_extract_chars", 150000))
526
+ ),
527
+ }
528
+
529
+
530
+ def _uploaded_file_identity(item, occurrence: int = 0) -> str:
531
+ provider_id = str(getattr(item, "file_id", "") or "").strip()
532
+ name = os.path.basename(str(getattr(item, "name", "") or ""))
533
+ size = str(getattr(item, "size", "") or "")
534
+ mime = str(getattr(item, "type", "") or "")
535
+ source = provider_id or f"{name}|{size}|{mime}|{occurrence}"
536
+ return hashlib.sha256(source.encode("utf-8")).hexdigest()[:24]
537
+
538
+
539
+ def _uploaded_material_entries() -> list[dict]:
540
+ uploads = list(st.session_state.get("up_research_materials") or [])
541
+ occurrences: dict[tuple[str, str, str], int] = {}
542
+ entries: list[dict] = []
543
+ for item in uploads:
544
+ name = os.path.basename(str(getattr(item, "name", "") or ""))
545
+ size = str(getattr(item, "size", "") or "")
546
+ mime = str(getattr(item, "type", "") or "")
547
+ signature = (name, size, mime)
548
+ occurrence = occurrences.get(signature, 0)
549
+ occurrences[signature] = occurrence + 1
550
+ identity = _uploaded_file_identity(item, occurrence)
551
+ key = f"sp_material_role_{identity}"
552
+ ext = Path(name).suffix.lower()
553
+ default_role = "research" if ext in {".txt", ".csv", ".xls", ".xlsx"} else "reference"
554
+ if key not in st.session_state:
555
+ st.session_state[key] = default_role
556
+ role = str(st.session_state.get(key) or default_role).strip().lower()
557
+ if role not in {"reference", "research"}:
558
+ role = default_role
559
+ entries.append(
560
+ {
561
+ "item": item,
562
+ "identity": identity,
563
+ "state_key": key,
564
+ "name": name,
565
+ "extension": ext,
566
+ "role": role,
567
+ }
568
+ )
569
+ return entries
570
+
571
+
572
+ def _material_role_counts(entries: list[dict] | None = None) -> dict[str, int]:
573
+ entries = list(entries if entries is not None else _uploaded_material_entries())
574
+ return {
575
+ "reference": sum(1 for entry in entries if entry.get("role") == "reference"),
576
+ "research": sum(1 for entry in entries if entry.get("role") == "research"),
577
+ }
578
+
579
+
580
+ def _material_limits_ok(entries: list[dict] | None = None) -> tuple[bool, dict[str, int], dict]:
581
+ entries = list(entries if entries is not None else _uploaded_material_entries())
582
+ counts = _material_role_counts(entries)
583
+ policy = _research_material_upload_policy()
584
+ ok = (
585
+ counts["reference"] <= policy["reference_file_limit"]
586
+ and counts["research"] <= policy["research_material_file_limit"]
587
+ )
588
+ return ok, counts, policy
589
+
590
+
591
+ def _explicit_material_bucket(method_family: str, role: str) -> str:
592
+ capability = method_capability(method_family)
593
+ material_routing = dict(capability.get("material_routing") or {})
594
+ route_key = "user_reference" if role == "reference" else "user_research"
595
+ bucket = str(material_routing.get(route_key) or "").strip().casefold()
596
+ if bucket not in {"reference", "data", "interview"}:
597
+ raise RuntimeError("method_material_routing_unsupported")
598
+ return bucket
599
+
600
+
601
+ def _material_preflight_report(method_family: str, entries: list[dict] | None = None) -> dict:
602
+ """Validate every selected file before task creation/quota reservation."""
603
+ entries = list(entries if entries is not None else _uploaded_material_entries())
604
+ limits_ok, counts, policy = _material_limits_ok(entries)
605
+ report = {
606
+ "ok": bool(limits_ok),
607
+ "counts": counts,
608
+ "policy": policy,
609
+ "failures": [],
610
+ "research_chars": 0,
611
+ }
612
+ if not limits_ok:
613
+ return report
614
+
615
+ cache = st.session_state.get("sp_material_parse_cache")
616
+ if not isinstance(cache, dict):
617
+ cache = {}
618
+ st.session_state["sp_material_parse_cache"] = cache
619
+
620
+ live_cache_keys = set()
621
+ for entry in entries:
622
+ item = entry["item"]
623
+ role = str(entry["role"])
624
+ cache_key = f"{entry['identity']}|{role}"
625
+ live_cache_keys.add(cache_key)
626
+ result = cache.get(cache_key)
627
+ if not isinstance(result, dict):
628
+ result = validate_file_parseable(item)
629
+ cache[cache_key] = dict(result)
630
+ if not bool(result.get("ok")):
631
+ report["failures"].append(
632
+ {
633
+ "name": entry["name"],
634
+ "role": role,
635
+ "error": str(result.get("error") or "parse_failed"),
636
+ }
637
+ )
638
+ elif role == "research":
639
+ report["research_chars"] += int(result.get("text_chars") or 0)
640
+
641
+ # Drop stale cache entries when files are removed or roles change.
642
+ st.session_state["sp_material_parse_cache"] = {
643
+ key: value for key, value in cache.items() if key in live_cache_keys
644
+ }
645
+
646
+ if report["failures"]:
647
+ report["ok"] = False
648
+ research_bucket = _explicit_material_bucket(method_family, "research")
649
+ if (
650
+ report["ok"]
651
+ and counts["research"] > 0
652
+ and research_bucket == "interview"
653
+ and report["research_chars"] < policy["qualitative_min_combined_chars"]
654
+ ):
655
+ report["ok"] = False
656
+ report["content_insufficient"] = True
657
+ return report
658
+
659
+
660
+ def _material_bundle_text(entries: list[dict], research_bucket: str) -> str:
661
+ research_entries = [entry for entry in entries if entry.get("role") == "research"]
662
+ if not research_entries:
663
+ return ""
664
+ policy = _research_material_upload_policy()
665
+ if research_bucket == "data":
666
+ total_budget = int(policy["quantitative_bundle_max_chars"])
667
+ elif research_bucket == "interview":
668
+ total_budget = int(policy["qualitative_bundle_max_chars"])
669
+ else:
670
+ raise RuntimeError("method_material_routing_unsupported")
671
+ per_file_budget = max(1, total_budget // len(research_entries))
672
+ sections = []
673
+ for entry in research_entries:
674
+ text = extract_file_content(entry["item"], max_chars=per_file_budget)
675
+ sections.append(f"### Source: {entry['name']}\n{text.strip()}")
676
+ return "\n\n".join(sections).strip()[:total_budget]
677
+
678
  def _uploaded_material_descriptors() -> list[dict[str, str]]:
679
  out: list[dict[str, str]] = []
680
  for item in st.session_state.get("up_research_materials") or []:
 
778
 
779
  def _classify_and_save_research_materials(
780
  method_family: str, inputs_dir: Path
781
+ ) -> tuple[list[str], list[str], list[str], str, list[dict]]:
782
+ """Persist all approved files while preserving the user's per-file role authority.
 
 
 
783
 
784
+ Research materials are combined into one deterministic text bundle so the
785
+ frozen GenerationRunner can continue using its proven single empirical-input
786
+ seam without silently dropping files 2..N.
787
+ """
788
+ entries = _uploaded_material_entries()
789
+ if not entries:
790
+ return [], [], [], "none", []
 
 
 
 
 
791
 
792
+ report = _material_preflight_report(method_family, entries)
793
+ if not report.get("ok"):
794
+ raise RuntimeError("research_material_preflight_failed")
795
 
796
+ reference_items = [entry["item"] for entry in entries if entry.get("role") == "reference"]
797
+ research_entries = [entry for entry in entries if entry.get("role") == "research"]
 
798
 
799
+ refs = _save_uploaded(reference_items, inputs_dir, "ref")
800
+ original_research = _save_uploaded(
801
+ [entry["item"] for entry in research_entries], inputs_dir, "research"
 
 
 
 
 
 
 
 
802
  )
803
 
804
+ data: list[str] = []
805
+ interviews: list[str] = []
806
+ capability = method_capability(method_family)
807
+ material_routing = dict(capability.get("material_routing") or {})
808
+ reference_bucket = str(material_routing.get("user_reference") or "").strip().casefold()
809
+ research_bucket = str(material_routing.get("user_research") or "").strip().casefold()
810
+ if reference_bucket != "reference" or research_bucket not in {"data", "interview"}:
811
+ raise RuntimeError("method_material_routing_unsupported")
812
+ if research_entries:
813
+ bundle_text = _material_bundle_text(research_entries, research_bucket)
814
+ if not bundle_text:
815
+ raise RuntimeError("research_material_bundle_empty")
816
+ bundle_name = "research_materials_bundle.txt"
817
+ bundle_path = inputs_dir / bundle_name
818
+ bundle_path.write_text(bundle_text, encoding="utf-8")
819
+ bundle_rel = str(Path("inputs") / bundle_name)
820
+ if research_bucket == "data":
821
+ data = [bundle_rel]
822
+ else:
823
+ interviews = [bundle_rel]
824
+
825
+ role_manifest = []
826
+ research_index = 0
827
+ reference_index = 0
828
+ for entry in entries:
829
+ role = str(entry.get("role") or "")
830
+ if role == "reference":
831
+ saved_path = refs[reference_index]
832
+ reference_index += 1
833
+ else:
834
+ saved_path = original_research[research_index]
835
+ research_index += 1
836
+ role_manifest.append(
837
+ {
838
+ "name": entry["name"],
839
+ "role": role,
840
+ "saved_path": saved_path,
841
+ }
842
+ )
843
 
844
+ return refs, data, interviews, "per_file_v1", role_manifest
845
 
846
  def _persist_task_request(
847
  task: dict,
 
864
  if not expected_lane or expected_lane != str(mode or "").strip().lower():
865
  raise RuntimeError("method_family_policy_lane_mismatch")
866
 
867
+ refs, data, interviews, material_use_decision, material_file_roles = _classify_and_save_research_materials(
868
  method_family, inputs_dir
869
  )
870
 
 
876
  "method_resolver_version": str(method_resolver_version or METHOD_RESOLVER_VERSION),
877
  "derived_policy_lane": expected_lane,
878
  "material_use_decision": material_use_decision,
879
+ "material_file_roles": material_file_roles,
880
  "interface_language": lang_code,
881
  "target_lang": get_target_language(lang_code),
882
  "output_suffix": get_output_suffix(lang_code),
 
1085
  st.warning(tr(lang_code, "method_resolution_invalid"))
1086
  return
1087
 
1088
+ material_gate = _material_preflight_report(
1089
+ pending_family, _uploaded_material_entries()
1090
+ )
1091
+ if not bool(material_gate.get("ok")):
1092
+ st.session_state["sp_pending_launch"] = None
1093
+ st.warning(tr(lang_code, "material_preflight_blocked"))
1094
+ return
1095
+
1096
  projection = get_product_ui_projection()
1097
  selected_product_tier = (
1098
  str(st.session_state.get("sp_product_tier") or "").strip().upper()
 
1790
  )
1791
 
1792
  uploaded_materials = list(st.session_state.get("up_research_materials") or [])
1793
+ material_entries = _uploaded_material_entries()
1794
+ if uploaded_materials and not active:
1795
+ for entry in material_entries:
1796
+ st.radio(
1797
+ entry["name"],
1798
+ ["reference", "research"],
1799
+ format_func=lambda value: tr(lang_code, f"material_use_{value}"),
1800
+ horizontal=True,
1801
+ key=entry["state_key"],
1802
+ )
1803
+ # Re-read after widgets because Streamlit may have changed role state.
1804
+ material_entries = _uploaded_material_entries()
1805
+ limits_ok, material_counts, material_policy = _material_limits_ok(material_entries)
1806
+ if material_counts["reference"] > material_policy["reference_file_limit"]:
1807
+ st.error(
1808
+ tr(
1809
+ lang_code,
1810
+ "material_reference_limit_exceeded",
1811
+ limit=material_policy["reference_file_limit"],
1812
+ )
1813
+ )
1814
+ if material_counts["research"] > material_policy["research_material_file_limit"]:
1815
+ st.error(
1816
+ tr(
1817
+ lang_code,
1818
+ "material_research_limit_exceeded",
1819
+ limit=material_policy["research_material_file_limit"],
1820
+ )
1821
+ )
1822
+ else:
1823
+ limits_ok = True
1824
+ material_counts = {"reference": 0, "research": 0}
1825
+ material_policy = _research_material_upload_policy()
1826
 
1827
  if active:
1828
  active_family = _task_method_family(task)
 
1861
  )
1862
  if active:
1863
  st.markdown(f"**{tr(lang_code, 'detected_method_label')}:** {method_label}")
1864
+
1865
+ material_gate = {"ok": True, "failures": []}
1866
+ if not active and limits_ok:
1867
+ material_gate = _material_preflight_report(method_family, material_entries)
1868
+ for failure in material_gate.get("failures") or []:
1869
+ role = str(failure.get("role") or "reference")
1870
+ formats_key = (
1871
+ "material_reference_convert_formats"
1872
+ if role == "reference"
1873
+ else "material_research_convert_formats"
1874
+ )
1875
+ st.error(
1876
+ tr(
1877
+ lang_code,
1878
+ "material_parse_failed",
1879
+ filename=str(failure.get("name") or ""),
1880
+ formats=tr(lang_code, formats_key),
1881
+ )
1882
+ )
1883
+ if material_gate.get("content_insufficient"):
1884
+ st.error(tr(lang_code, "material_research_content_insufficient"))
1885
+ elif not active:
1886
+ material_gate = {"ok": False, "failures": []}
1887
+
1888
  st.caption(tr(lang_code, "method_detection_explanation"))
1889
 
1890
  block_reason = str(preflight.get("block_reason") or "")
 
1989
  st.rerun()
1990
  can_start = bool(
1991
  preflight.get("can_start_generation")
1992
+ and bool(material_gate.get("ok"))
1993
  and product_ui_ready
1994
  and selected_remaining is not None
1995
  and selected_remaining > 0
 
2007
  click_preflight, click_signature = _current_method_preflight(
2008
  confirmed_method_family=launch_family
2009
  )
2010
+ click_material_gate = _material_preflight_report(
2011
+ launch_family, _uploaded_material_entries()
2012
+ )
2013
  if (
2014
  not launch_family
2015
  or not bool(click_preflight.get("can_start_generation"))
2016
  or str(click_preflight.get("method_family") or "").strip().casefold() != launch_family
2017
  ):
2018
  st.warning(tr(lang_code, "method_resolution_invalid"))
2019
+ elif not bool(click_material_gate.get("ok")):
2020
+ st.warning(tr(lang_code, "material_preflight_blocked"))
2021
  else:
2022
  resolved_mode = str(click_preflight.get("legacy_policy_lane") or "").strip().lower()
2023
  capability = dict(click_preflight.get("capability") or {})
config.py CHANGED
@@ -68,6 +68,37 @@ def load_config():
68
  "rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
69
  "rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
70
  "max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  # Model requests may legitimately run longer than one Worker lease.
72
  # The independent Executor renews authority continuously; model-token
73
  # activity is observability only and never the liveness authority.
 
68
  "rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
69
  "rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
70
  "max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
71
+ # P0-R7-B: upload-role limits are policy-driven and centrally owned.
72
+ # app.py reads this policy; it must not embed business-count literals.
73
+ "research_material_upload_policy": {
74
+ "version": os.environ.get(
75
+ "SMARTPAPER_RESEARCH_MATERIAL_UPLOAD_POLICY_VERSION",
76
+ "research-material-upload-policy-v1",
77
+ ).strip() or "research-material-upload-policy-v1",
78
+ "reference_file_limit": max(
79
+ 1,
80
+ min(100, int(os.environ.get("SMARTPAPER_REFERENCE_FILE_LIMIT", "10"))),
81
+ ),
82
+ "research_material_file_limit": max(
83
+ 1,
84
+ min(100, int(os.environ.get("SMARTPAPER_RESEARCH_MATERIAL_FILE_LIMIT", "10"))),
85
+ ),
86
+ # Preserve the existing qualitative engine's >100-character
87
+ # user-evidence threshold without duplicating it in UI code.
88
+ "qualitative_min_combined_chars": max(
89
+ 1,
90
+ int(os.environ.get("SMARTPAPER_QUALITATIVE_MIN_USER_MATERIAL_CHARS", "101")),
91
+ ),
92
+ "quantitative_bundle_max_chars": max(
93
+ 1000,
94
+ int(os.environ.get("SMARTPAPER_QUANTITATIVE_RESEARCH_BUNDLE_MAX_CHARS", "20000")),
95
+ ),
96
+ "qualitative_bundle_max_chars": max(
97
+ 1000,
98
+ int(os.environ.get("SMARTPAPER_QUALITATIVE_RESEARCH_BUNDLE_MAX_CHARS", os.environ.get("MAX_EXTRACT_CHARS", "150000"))),
99
+ ),
100
+ "accepted_extensions": ["pdf", "txt", "doc", "docx", "csv", "xls", "xlsx"],
101
+ },
102
  # Model requests may legitimately run longer than one Worker lease.
103
  # The independent Executor renews authority continuously; model-token
104
  # activity is observability only and never the liveness authority.
core/advanced_method_registry.py CHANGED
@@ -39,6 +39,9 @@ _METHOD_CAPABILITIES: dict[str, dict[str, Any]] = {
39
  "text": "interview",
40
  "document_reference": "reference",
41
  "document_research": "interview",
 
 
 
42
  },
43
  },
44
  "survey_path": {
@@ -53,6 +56,9 @@ _METHOD_CAPABILITIES: dict[str, dict[str, Any]] = {
53
  "text": "reference",
54
  "document_reference": "reference",
55
  "document_research": "reference",
 
 
 
56
  },
57
  },
58
  }
@@ -337,7 +343,10 @@ def resolve_generation_preflight(
337
  template_profile = str(capability.get("template_profile") or "").strip()
338
  material_profile = str(capability.get("material_profile") or "").strip()
339
  material_routing = dict(capability.get("material_routing") or {})
340
- routing_keys = {"structured", "text", "document_reference", "document_research"}
 
 
 
341
  routing_values = {str(value or "").strip() for value in material_routing.values()}
342
  if (
343
  family not in METHOD_FAMILIES
 
39
  "text": "interview",
40
  "document_reference": "reference",
41
  "document_research": "interview",
42
+ # P0-R7-B: explicit per-file user roles remain registry-owned.
43
+ "user_reference": "reference",
44
+ "user_research": "interview",
45
  },
46
  },
47
  "survey_path": {
 
56
  "text": "reference",
57
  "document_reference": "reference",
58
  "document_research": "reference",
59
+ # P0-R7-B: user research material is empirical evidence for survey work.
60
+ "user_reference": "reference",
61
+ "user_research": "data",
62
  },
63
  },
64
  }
 
343
  template_profile = str(capability.get("template_profile") or "").strip()
344
  material_profile = str(capability.get("material_profile") or "").strip()
345
  material_routing = dict(capability.get("material_routing") or {})
346
+ routing_keys = {
347
+ "structured", "text", "document_reference", "document_research",
348
+ "user_reference", "user_research",
349
+ }
350
  routing_values = {str(value or "").strip() for value in material_routing.values()}
351
  if (
352
  family not in METHOD_FAMILIES
i18n.py CHANGED
@@ -284,6 +284,13 @@ TEXTS: dict[str, dict[str, str]] = {
284
  "material_document_use_label": "如何使用上传的 PDF/DOC/DOCX 文件?",
285
  "material_use_reference": "作为参考文献资料",
286
  "material_use_research": "作为研究资料",
 
 
 
 
 
 
 
287
  "detected_method_label": "研究设计(可调整)",
288
  "method_detection_explanation": "默认为质性研究;生成前可根据需要调整为问卷量化研究。",
289
  "method_confirm_label": "调整研究设计",
@@ -532,6 +539,13 @@ TEXTS: dict[str, dict[str, str]] = {
532
  "material_document_use_label": "How should SmartPaper use the uploaded PDF/DOC/DOCX files?",
533
  "material_use_reference": "Use as reference literature",
534
  "material_use_research": "Use as research materials",
 
 
 
 
 
 
 
535
  "detected_method_label": "Research Design (Adjustable)",
536
  "method_detection_explanation": "Qualitative Research is selected by default. Before generation, you can adjust the Research Design to Survey-Based Quantitative Research.",
537
  "method_confirm_label": "Adjust Research Design",
@@ -780,6 +794,13 @@ TEXTS: dict[str, dict[str, str]] = {
780
  "material_document_use_label": "Как использовать загруженные файлы PDF/DOC/DOCX?",
781
  "material_use_reference": "Как научные источники",
782
  "material_use_research": "Как исследовательские материалы",
 
 
 
 
 
 
 
783
  "detected_method_label": "Дизайн исследования (можно изменить)",
784
  "method_detection_explanation": "По умолчанию выбрано качественное исследование. До начала генерации дизайн можно изменить на количественное исследование на основе опроса.",
785
  "method_confirm_label": "Изменить дизайн исследования",
@@ -1028,6 +1049,13 @@ TEXTS: dict[str, dict[str, str]] = {
1028
  "material_document_use_label": "Жүктелген PDF/DOC/DOCX файлдарын қалай пайдалану керек?",
1029
  "material_use_reference": "Ғылыми дереккөз ретінде",
1030
  "material_use_research": "Зерттеу материалы ретінде",
 
 
 
 
 
 
 
1031
  "detected_method_label": "Зерттеу дизайны (өзгертуге болады)",
1032
  "method_detection_explanation": "Әдепкі бойынша сапалық зерттеу таңдалады. Генерацияны бастамас бұрын зерттеу дизайнын сауалнамаға негізделген сандық зерттеуге өзгертуге болады.",
1033
  "method_confirm_label": "Зерттеу дизайнын өзгерту",
 
284
  "material_document_use_label": "如何使用上传的 PDF/DOC/DOCX 文件?",
285
  "material_use_reference": "作为参考文献资料",
286
  "material_use_research": "作为研究资料",
287
+ "material_reference_limit_exceeded": "参考文献最多可选择 {limit} 个,请调整文件用途或删除多余文件。",
288
+ "material_research_limit_exceeded": "研究资料最多可选择 {limit} 个,请调整文件用途或删除多余文件。",
289
+ "material_parse_failed": "文件“{filename}”无法被系统正确解析。请先转换为可识别格式后重新上传(建议:{formats})。",
290
+ "material_reference_convert_formats": "PDF、DOCX 或 TXT",
291
+ "material_research_convert_formats": "PDF、DOCX、TXT、CSV 或 XLSX",
292
+ "material_research_content_insufficient": "已选择的研究资料可解析内容不足,系统无法可靠识别。请补充内容或转换为可正确识别的格式后重新上传。",
293
+ "material_preflight_blocked": "研究资料预检未通过,请先处理上方文件问题;系统尚未创建任务或预留额度。",
294
  "detected_method_label": "研究设计(可调整)",
295
  "method_detection_explanation": "默认为质性研究;生成前可根据需要调整为问卷量化研究。",
296
  "method_confirm_label": "调整研究设计",
 
539
  "material_document_use_label": "How should SmartPaper use the uploaded PDF/DOC/DOCX files?",
540
  "material_use_reference": "Use as reference literature",
541
  "material_use_research": "Use as research materials",
542
+ "material_reference_limit_exceeded": "You can select at most {limit} reference files. Change file roles or remove extra files.",
543
+ "material_research_limit_exceeded": "You can select at most {limit} research-material files. Change file roles or remove extra files.",
544
+ "material_parse_failed": "The system cannot reliably parse “{filename}”. Convert it to a supported readable format and upload it again (recommended: {formats}).",
545
+ "material_reference_convert_formats": "PDF, DOCX, or TXT",
546
+ "material_research_convert_formats": "PDF, DOCX, TXT, CSV, or XLSX",
547
+ "material_research_content_insufficient": "The selected research materials do not contain enough parseable content for reliable use. Add content or convert the files to a readable format and upload them again.",
548
+ "material_preflight_blocked": "Research-material preflight did not pass. Resolve the file issues above first; no task or quota reservation has been created.",
549
  "detected_method_label": "Research Design (Adjustable)",
550
  "method_detection_explanation": "Qualitative Research is selected by default. Before generation, you can adjust the Research Design to Survey-Based Quantitative Research.",
551
  "method_confirm_label": "Adjust Research Design",
 
794
  "material_document_use_label": "Как использовать загруженные файлы PDF/DOC/DOCX?",
795
  "material_use_reference": "Как научные источники",
796
  "material_use_research": "Как исследовательские материалы",
797
+ "material_reference_limit_exceeded": "Можно выбрать не более {limit} файлов в качестве научных источников. Измените назначение файлов или удалите лишние.",
798
+ "material_research_limit_exceeded": "Можно выбрать не более {limit} файлов в качестве исследовательских материалов. Измените назначение файлов или удалите лишние.",
799
+ "material_parse_failed": "Система не может корректно распознать файл «{filename}». Преобразуйте его в поддерживаемый читаемый формат и загрузите снова (рекомендуется: {formats}).",
800
+ "material_reference_convert_formats": "PDF, DOCX или TXT",
801
+ "material_research_convert_formats": "PDF, DOCX, TXT, CSV или XLSX",
802
+ "material_research_content_insufficient": "В выбранных исследовательских материалах недостаточно корректно распознаваемого содержимого. Дополните материалы или преобразуйте файлы в читаемый формат и загрузите их снова.",
803
+ "material_preflight_blocked": "Предварительная проверка исследовательских материалов не пройдена. Сначала устраните указанные выше проблемы; задача и резерв квоты ещё не созданы.",
804
  "detected_method_label": "Дизайн исследования (можно изменить)",
805
  "method_detection_explanation": "По умолчанию выбрано качественное исследование. До начала генерации дизайн можно изменить на количественное исследование на основе опроса.",
806
  "method_confirm_label": "Изменить дизайн исследования",
 
1049
  "material_document_use_label": "Жүктелген PDF/DOC/DOCX файлдарын қалай пайдалану керек?",
1050
  "material_use_reference": "Ғылыми дереккөз ретінде",
1051
  "material_use_research": "Зерттеу материалы ретінде",
1052
+ "material_reference_limit_exceeded": "Ғылыми дереккөз ретінде ең көбі {limit} файл таңдауға болады. Файлдардың мақсатын өзгертіңіз немесе артық файлдарды жойыңыз.",
1053
+ "material_research_limit_exceeded": "Зерттеу материалы ретінде ең көбі {limit} файл таңдауға болады. Файлдардың мақсатын өзгертіңіз немесе артық файлдарды жойыңыз.",
1054
+ "material_parse_failed": "Жүйе «{filename}» файлын сенімді түрде тани алмады. Оны оқылатын қолдау көрсетілетін пішімге түрлендіріп, қайта жүктеңіз (ұсынылады: {formats}).",
1055
+ "material_reference_convert_formats": "PDF, DOCX немесе TXT",
1056
+ "material_research_convert_formats": "PDF, DOCX, TXT, CSV немесе XLSX",
1057
+ "material_research_content_insufficient": "Таңдалған зерттеу материалдарында сенімді пайдалануға жеткілікті танылатын мазмұн жоқ. Мазмұнды толықтырыңыз немесе файлдарды оқылатын пішімге түрлендіріп, қайта жүктеңіз.",
1058
+ "material_preflight_blocked": "Зерттеу материалдарының алдын ала тексеруі өтпеді. Алдымен жоғарыдағы файл мәселелерін түзетіңіз; тапсырма да, квота резерві де әлі жасалған жоқ.",
1059
  "detected_method_label": "Зерттеу дизайны (өзгертуге болады)",
1060
  "method_detection_explanation": "Әдепкі бойынша сапалық зерттеу таңдалады. Генерацияны бастамас бұрын зерттеу дизайнын сауалнамаға негізделген сандық зерттеуге өзгертуге болады.",
1061
  "method_confirm_label": "Зерттеу дизайнын өзгерту",
utils/file_parser.py CHANGED
@@ -1,5 +1,9 @@
1
  # -*- coding: utf-8 -*-
2
- """Headless file parsing helpers used by both Streamlit and R3-Free executor."""
 
 
 
 
3
  from __future__ import annotations
4
 
5
  import io
@@ -24,6 +28,10 @@ except ImportError:
24
  PYMUPDF_AVAILABLE = False
25
 
26
 
 
 
 
 
27
  def _name(file) -> str:
28
  if isinstance(file, (str, os.PathLike, Path)):
29
  return os.fspath(file)
@@ -58,74 +66,157 @@ def _binary_stream(file) -> io.BytesIO | BinaryIO:
58
  return file
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def extract_text_from_files(uploaded_files: Iterable, max_chars=None):
62
  if max_chars is None:
63
  max_chars = APP_CONFIG.get("max_extract_chars", 150000)
64
 
65
- extracted_text = ""
 
66
  for file in uploaded_files or []:
67
  filename = _name(file)
68
- lower_name = filename.lower()
69
  try:
70
- if lower_name.endswith(".pdf") and PYMUPDF_AVAILABLE:
71
- pdf_bytes = _read_bytes(file)
72
- with pymupdf.open(stream=pdf_bytes, filetype="pdf") as pdf_doc:
73
- for page in pdf_doc:
74
- text = page.get_text()
75
- if text:
76
- extracted_text += text + "\n"
77
- if len(extracted_text) > max_chars:
78
- break
79
- elif lower_name.endswith((".docx", ".doc")):
80
- stream = _binary_stream(file)
81
- doc = Document(stream)
82
- for para in doc.paragraphs:
83
- extracted_text += para.text + "\n"
84
- if len(extracted_text) > max_chars:
85
- break
86
- else:
87
- extracted_text += _read_bytes(file).decode("utf-8", errors="ignore") + "\n"
88
-
89
- if len(extracted_text) > max_chars:
90
- logging.warning("File %s was truncated safely to %s characters.", filename, max_chars)
91
  break
92
  except Exception as exc:
 
 
93
  logging.warning("File parse failed for %s: %s", filename, exc)
94
- finally:
95
- if not isinstance(file, (str, os.PathLike, Path)):
96
- try:
97
- file.seek(0)
98
- except Exception:
99
- pass
100
- return extracted_text[:max_chars]
101
 
102
 
103
  def extract_data_from_files(uploaded_file, max_chars=20000):
104
  if not uploaded_file:
105
  return ""
106
-
107
- filename = _name(uploaded_file)
108
- lower_name = filename.lower()
109
  try:
110
- if PANDAS_AVAILABLE:
111
- source = os.fspath(uploaded_file) if isinstance(uploaded_file, (str, os.PathLike, Path)) else uploaded_file
112
- if not isinstance(uploaded_file, (str, os.PathLike, Path)):
113
- try:
114
- uploaded_file.seek(0)
115
- except Exception:
116
- pass
117
- df = pd.read_csv(source) if lower_name.endswith(".csv") else pd.read_excel(source)
118
- if len(df) > 100:
119
- logging.warning("Data file %s truncated safely to first 100 rows.", filename)
120
- df = df.head(100)
121
- return df.to_markdown(index=False)
122
- return _read_bytes(uploaded_file).decode("utf-8", errors="ignore")[:max_chars]
123
  except Exception as exc:
124
- logging.warning("Data parse failed for %s: %s", filename, exc)
125
  return ""
126
- finally:
127
- if not isinstance(uploaded_file, (str, os.PathLike, Path)):
128
- try:
129
- uploaded_file.seek(0)
130
- except Exception:
131
- pass
 
1
  # -*- coding: utf-8 -*-
2
+ """Headless file parsing helpers used by both Streamlit and R3-Free executor.
3
+
4
+ P0-R7-B keeps parsing fail-soft for historical generation calls, while exposing
5
+ an explicit validator that the UI can run before task creation/quota reservation.
6
+ """
7
  from __future__ import annotations
8
 
9
  import io
 
28
  PYMUPDF_AVAILABLE = False
29
 
30
 
31
+ class FileParseError(RuntimeError):
32
+ """A user file cannot be parsed reliably by the current runtime."""
33
+
34
+
35
  def _name(file) -> str:
36
  if isinstance(file, (str, os.PathLike, Path)):
37
  return os.fspath(file)
 
66
  return file
67
 
68
 
69
+ def _decode_text_bytes(data: bytes) -> str:
70
+ if not data:
71
+ return ""
72
+ for encoding in ("utf-8-sig", "utf-8", "utf-16", "gb18030"):
73
+ try:
74
+ return data.decode(encoding)
75
+ except UnicodeDecodeError:
76
+ continue
77
+ raise FileParseError("text_encoding_unsupported")
78
+
79
+
80
+ def _read_table(file, lower_name: str):
81
+ if not PANDAS_AVAILABLE:
82
+ raise FileParseError("pandas_unavailable")
83
+ source = os.fspath(file) if isinstance(file, (str, os.PathLike, Path)) else file
84
+ if not isinstance(file, (str, os.PathLike, Path)):
85
+ try:
86
+ file.seek(0)
87
+ except Exception:
88
+ pass
89
+ if lower_name.endswith(".csv"):
90
+ last_exc = None
91
+ for encoding in ("utf-8-sig", "utf-8", "gb18030"):
92
+ try:
93
+ if not isinstance(file, (str, os.PathLike, Path)):
94
+ file.seek(0)
95
+ return pd.read_csv(source, encoding=encoding)
96
+ except UnicodeDecodeError as exc:
97
+ last_exc = exc
98
+ continue
99
+ if last_exc:
100
+ raise last_exc
101
+ return pd.read_csv(source)
102
+ return pd.read_excel(source)
103
+
104
+
105
+ def extract_file_content(file, max_chars=None) -> str:
106
+ """Parse one supported user file and raise FileParseError on failure.
107
+
108
+ The returned text is suitable for either literature context or a bounded
109
+ research-material bundle. Structured files are converted to Markdown.
110
+ """
111
+ if max_chars is None:
112
+ max_chars = APP_CONFIG.get("max_extract_chars", 150000)
113
+ max_chars = max(1, int(max_chars))
114
+ filename = _name(file)
115
+ lower_name = filename.lower()
116
+ try:
117
+ if lower_name.endswith(".pdf"):
118
+ if not PYMUPDF_AVAILABLE:
119
+ raise FileParseError("pdf_parser_unavailable")
120
+ pdf_bytes = _read_bytes(file)
121
+ parts = []
122
+ with pymupdf.open(stream=pdf_bytes, filetype="pdf") as pdf_doc:
123
+ for page in pdf_doc:
124
+ text = page.get_text()
125
+ if text:
126
+ parts.append(text)
127
+ if sum(len(part) for part in parts) >= max_chars:
128
+ break
129
+ result = "\n".join(parts)
130
+ elif lower_name.endswith((".docx", ".doc")):
131
+ stream = _binary_stream(file)
132
+ doc = Document(stream)
133
+ parts = []
134
+ total = 0
135
+ for para in doc.paragraphs:
136
+ value = str(para.text or "")
137
+ if value:
138
+ parts.append(value)
139
+ total += len(value)
140
+ if total >= max_chars:
141
+ break
142
+ result = "\n".join(parts)
143
+ elif lower_name.endswith((".csv", ".xls", ".xlsx")):
144
+ df = _read_table(file, lower_name)
145
+ if len(df) > 100:
146
+ logging.warning("Data file %s truncated safely to first 100 rows.", filename)
147
+ df = df.head(100)
148
+ result = df.to_markdown(index=False)
149
+ elif lower_name.endswith((".txt", ".md")):
150
+ result = _decode_text_bytes(_read_bytes(file))
151
+ else:
152
+ raise FileParseError("unsupported_file_extension")
153
+ except FileParseError:
154
+ raise
155
+ except Exception as exc:
156
+ raise FileParseError(f"parse_failed:{exc.__class__.__name__}") from exc
157
+ finally:
158
+ if not isinstance(file, (str, os.PathLike, Path)):
159
+ try:
160
+ file.seek(0)
161
+ except Exception:
162
+ pass
163
+
164
+ normalized = str(result or "").strip()
165
+ if not normalized:
166
+ raise FileParseError("parsed_content_empty")
167
+ return normalized[:max_chars]
168
+
169
+
170
+ def validate_file_parseable(file, max_chars=4000) -> dict:
171
+ """Return an explicit, UI-safe parseability result for one file."""
172
+ filename = os.path.basename(_name(file))
173
+ try:
174
+ content = extract_file_content(file, max_chars=max_chars)
175
+ return {
176
+ "ok": True,
177
+ "filename": filename,
178
+ "text_chars": len(content),
179
+ "error": "",
180
+ }
181
+ except Exception as exc:
182
+ return {
183
+ "ok": False,
184
+ "filename": filename,
185
+ "text_chars": 0,
186
+ "error": str(exc)[:160],
187
+ }
188
+
189
+
190
  def extract_text_from_files(uploaded_files: Iterable, max_chars=None):
191
  if max_chars is None:
192
  max_chars = APP_CONFIG.get("max_extract_chars", 150000)
193
 
194
+ extracted = []
195
+ total = 0
196
  for file in uploaded_files or []:
197
  filename = _name(file)
 
198
  try:
199
+ remaining = max(1, int(max_chars) - total)
200
+ text = extract_file_content(file, max_chars=remaining)
201
+ extracted.append(text)
202
+ total += len(text)
203
+ if total >= int(max_chars):
204
+ logging.warning("File %s caused aggregate text to reach %s characters.", filename, max_chars)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  break
206
  except Exception as exc:
207
+ # Historical generation calls remain fail-soft. P0-R7-B's UI gate
208
+ # uses validate_file_parseable() before task/quota creation.
209
  logging.warning("File parse failed for %s: %s", filename, exc)
210
+ return "\n".join(extracted)[: int(max_chars)]
 
 
 
 
 
 
211
 
212
 
213
  def extract_data_from_files(uploaded_file, max_chars=20000):
214
  if not uploaded_file:
215
  return ""
 
 
 
216
  try:
217
+ # P0-R7-B research bundles are UTF-8 text. Existing CSV/XLS/XLSX and
218
+ # document inputs remain supported through the same parser.
219
+ return extract_file_content(uploaded_file, max_chars=max_chars)
 
 
 
 
 
 
 
 
 
 
220
  except Exception as exc:
221
+ logging.warning("Data parse failed for %s: %s", _name(uploaded_file), exc)
222
  return ""