P0-R7-D-R2 research-material-only reliability closure

#148
.streamlit/config.toml CHANGED
@@ -3,7 +3,7 @@ enableStaticServing = true
3
  enableCORS = false
4
  enableXsrfProtection = false
5
  enableWebsocketCompression = false
6
- maxUploadSize = 1024
7
 
8
  [browser]
9
  gatherUsageStats = false
 
3
  enableCORS = false
4
  enableXsrfProtection = false
5
  enableWebsocketCompression = false
6
+ maxUploadSize = 10
7
 
8
  [browser]
9
  gatherUsageStats = false
app.py CHANGED
@@ -39,7 +39,11 @@ from core.advanced_method_registry import (
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",
@@ -507,14 +511,18 @@ def _save_uploaded(items, inputs_dir: Path, prefix: str) -> list[str]:
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
  ),
@@ -537,6 +545,7 @@ def _uploaded_file_identity(item, occurrence: int = 0) -> str:
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] = []
@@ -547,23 +556,14 @@ def _uploaded_material_entries() -> list[dict]:
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
@@ -571,81 +571,165 @@ def _uploaded_material_entries() -> list[dict]:
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
@@ -656,10 +740,8 @@ def _material_preflight_report(method_family: str, entries: list[dict] | None =
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":
@@ -668,12 +750,19 @@ def _material_bundle_text(entries: list[dict], research_bucket: str) -> str:
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]] = []
@@ -697,7 +786,7 @@ def _method_intent_signature() -> str:
697
  "topic": _normalized_research_intent_text(
698
  st.session_state.get("sp_topic") or DEFAULT_TOPIC
699
  ),
700
- "keywords": _normalized_research_intent_text(
701
  st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS
702
  ),
703
  }
@@ -770,7 +859,7 @@ def _current_method_preflight(*, confirmed_method_family: str | None = None) ->
770
  )
771
  preflight = resolve_generation_preflight(
772
  str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
773
- str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
774
  materials=_uploaded_material_descriptors(),
775
  confirmed_method_family=confirmed,
776
  )
@@ -779,11 +868,11 @@ def _current_method_preflight(*, confirmed_method_family: str | None = None) ->
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:
@@ -793,55 +882,36 @@ def _classify_and_save_research_materials(
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,
@@ -881,7 +951,7 @@ def _persist_task_request(
881
  "target_lang": get_target_language(lang_code),
882
  "output_suffix": get_output_suffix(lang_code),
883
  "topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
884
- "keywords": str(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
885
  "target_words": int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
886
  # High-entropy, display-only token. It is part of the immutable task
887
  # request/hash, but it grants no Worker/D1 authority.
@@ -1157,8 +1227,8 @@ def _process_pending_launch(lang_code: str) -> None:
1157
 
1158
  inject_wechat_browser_notice()
1159
 
1160
- DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning: Evaluating a Chatbot-based Recommendation Engine for Personalized and Context-aware Education"
1161
- DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized educational; Educational technologies; HCI; Recommendation system; LLMs; Generative AI; ChatGPT; Prompting engineering; Few-shot prompting"
1162
 
1163
  def _current_language_code() -> str:
1164
  choice = st.session_state.get("interface_language_choice")
@@ -1778,52 +1848,9 @@ def render_workspace_controls(user):
1778
  key="sp_keywords",
1779
  )
1780
 
1781
- # U1-R2 unified research workspace: academic intent/materials first, then
1782
- # product/length controls. Legacy AMJ/ASQ remains internal policy routing.
1783
- st.file_uploader(
1784
- tr(lang_code, "research_materials_label"),
1785
- type=["pdf", "txt", "doc", "docx", "csv", "xls", "xlsx"],
1786
- accept_multiple_files=True,
1787
- key="up_research_materials",
1788
- disabled=active,
1789
- help=tr(lang_code, "research_materials_help"),
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)
1829
  active_capability = method_capability(active_family)
@@ -1862,29 +1889,6 @@ def render_workspace_controls(user):
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 "")
@@ -1914,6 +1918,85 @@ def render_workspace_controls(user):
1914
  else:
1915
  st.markdown(tr(lang_code, "template_help_undetermined"))
1916
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1917
  product_ui = get_product_ui_projection()
1918
  product_ui_ready = product_ui.get("mode") == "a5_product"
1919
  selected_remaining: int | None = None
 
39
  reconcile_method_authority, resolve_generation_preflight,
40
  )
41
  from smartpaper_restore_bridge import edge_bridge
42
+ from utils.file_parser import (
43
+ extract_research_material_content,
44
+ research_material_text_fingerprint,
45
+ validate_research_material_template,
46
+ )
47
 
48
  st.set_page_config(
49
  page_title="Article Agent",
 
511
 
512
  def _research_material_upload_policy() -> dict:
513
  policy = dict(APP_CONFIG.get("research_material_upload_policy") or {})
 
514
  research_limit = int(policy.get("research_material_file_limit") or 0)
515
+ if research_limit <= 0:
516
  raise RuntimeError("research_material_upload_policy_invalid")
517
  return {
518
  **policy,
 
519
  "research_material_file_limit": research_limit,
520
+ "research_material_file_max_bytes": max(
521
+ 1024, int(policy.get("research_material_file_max_bytes") or 0)
522
+ ),
523
+ "research_material_total_max_bytes": max(
524
+ 1024, int(policy.get("research_material_total_max_bytes") or 0)
525
+ ),
526
  "qualitative_min_combined_chars": max(
527
  1, int(policy.get("qualitative_min_combined_chars") or 1)
528
  ),
 
545
 
546
 
547
  def _uploaded_material_entries() -> list[dict]:
548
+ """Return research-material-only upload entries for the current design."""
549
  uploads = list(st.session_state.get("up_research_materials") or [])
550
  occurrences: dict[tuple[str, str, str], int] = {}
551
  entries: list[dict] = []
 
556
  signature = (name, size, mime)
557
  occurrence = occurrences.get(signature, 0)
558
  occurrences[signature] = occurrence + 1
 
 
 
 
 
 
 
 
 
559
  entries.append(
560
  {
561
  "item": item,
562
+ "identity": _uploaded_file_identity(item, occurrence),
 
563
  "name": name,
564
+ "extension": Path(name).suffix.lower(),
565
+ "size_bytes": int(getattr(item, "size", 0) or 0),
566
+ "role": "research",
567
  }
568
  )
569
  return entries
 
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
+ # Keep the legacy reference key only for backward diagnostic compatibility.
575
+ # New-task UI exposes research materials only.
576
+ return {"reference": 0, "research": len(entries)}
 
577
 
578
 
579
  def _material_limits_ok(entries: list[dict] | None = None) -> tuple[bool, dict[str, int], dict]:
580
  entries = list(entries if entries is not None else _uploaded_material_entries())
581
  counts = _material_role_counts(entries)
582
  policy = _research_material_upload_policy()
583
+ ok = counts["research"] <= policy["research_material_file_limit"]
 
 
 
584
  return ok, counts, policy
585
 
586
 
587
+ def _research_material_extension_contract(method_family: str) -> tuple[str, ...]:
588
+ capability = method_capability(method_family)
589
+ extensions = tuple(
590
+ str(item or "").strip().casefold().lstrip(".")
591
+ for item in (capability.get("research_material_extensions") or ())
592
+ if str(item or "").strip()
593
+ )
594
+ if len(extensions) != 1 or extensions[0] not in {"txt", "csv"}:
595
+ raise RuntimeError("method_research_material_extension_unsupported")
596
+ return extensions
597
+
598
+
599
+ def _explicit_material_bucket(method_family: str, role: str = "research") -> str:
600
+ if str(role or "research").strip().casefold() != "research":
601
+ raise RuntimeError("reference_upload_not_supported")
602
  capability = method_capability(method_family)
603
  material_routing = dict(capability.get("material_routing") or {})
604
+ bucket = str(material_routing.get("user_research") or "").strip().casefold()
605
+ if bucket not in {"data", "interview"}:
 
606
  raise RuntimeError("method_material_routing_unsupported")
607
  return bucket
608
 
609
 
610
+ def _canonical_user_keywords(value: object) -> str:
611
+ """Normalize all supported user separators while preserving phrase spaces."""
612
+ terms = []
613
+ seen = set()
614
+ for raw in re.split(r"[;;,,\n|]+", str(value or "")):
615
+ term = re.sub(r"\s+", " ", raw).strip()
616
+ key = term.casefold()
617
+ if not term or key in seen:
618
+ continue
619
+ terms.append(term)
620
+ seen.add(key)
621
+ return "; ".join(terms)
622
+
623
+
624
+ def _template_content_fingerprints(template_profile: str) -> set[str]:
625
+ out: set[str] = set()
626
+ for language_code in LANGUAGE_OPTIONS.values():
627
+ content = str(get_research_material_template(language_code, template_profile).get("content") or "")
628
+ if content.strip():
629
+ out.add(research_material_text_fingerprint(content))
630
+ return out
631
+
632
+
633
  def _material_preflight_report(method_family: str, entries: list[dict] | None = None) -> dict:
634
+ """Validate research files and capacity before task creation/quota reservation."""
635
  entries = list(entries if entries is not None else _uploaded_material_entries())
636
  limits_ok, counts, policy = _material_limits_ok(entries)
637
+ capability = method_capability(method_family)
638
+ template_profile = str(capability.get("template_profile") or "").strip()
639
+ expected_extensions = _research_material_extension_contract(method_family)
640
+ research_bucket = _explicit_material_bucket(method_family)
641
+ bundle_limit = int(
642
+ policy["quantitative_bundle_max_chars"]
643
+ if research_bucket == "data"
644
+ else policy["qualitative_bundle_max_chars"]
645
+ )
646
+ total_bytes = sum(max(0, int(entry.get("size_bytes") or 0)) for entry in entries)
647
  report = {
648
  "ok": bool(limits_ok),
649
  "counts": counts,
650
  "policy": policy,
651
  "failures": [],
652
  "research_chars": 0,
653
+ "bundle_chars": 0,
654
+ "total_bytes": total_bytes,
655
+ "template_profile": template_profile,
656
+ "expected_extensions": expected_extensions,
657
+ "bundle_limit_chars": bundle_limit,
658
  }
659
  if not limits_ok:
660
  return report
661
+ if total_bytes > int(policy["research_material_total_max_bytes"]):
662
+ report["ok"] = False
663
+ report["total_size_exceeded"] = True
664
+ return report
665
 
666
  cache = st.session_state.get("sp_material_parse_cache")
667
  if not isinstance(cache, dict):
668
  cache = {}
669
  st.session_state["sp_material_parse_cache"] = cache
670
 
671
+ template_fingerprints = _template_content_fingerprints(template_profile)
672
  live_cache_keys = set()
673
+ successful_entries = 0
674
  for entry in entries:
675
  item = entry["item"]
676
+ ext = str(entry.get("extension") or "").casefold().lstrip(".")
677
+ cache_key = f"{entry['identity']}|{template_profile}|r2"
678
  live_cache_keys.add(cache_key)
679
+ size_bytes = max(0, int(entry.get("size_bytes") or 0))
680
+ if size_bytes > int(policy["research_material_file_max_bytes"]):
681
+ result = {
682
+ "ok": False,
683
+ "filename": entry["name"],
684
+ "text_chars": 0,
685
+ "byte_size": size_bytes,
686
+ "error": "research_material_file_too_large",
687
+ }
688
+ elif ext not in expected_extensions:
689
+ result = {
690
+ "ok": False,
691
+ "filename": entry["name"],
692
+ "text_chars": 0,
693
+ "byte_size": size_bytes,
694
+ "error": f"template_extension_mismatch:expected={expected_extensions[0]}",
695
+ }
696
+ else:
697
+ result = cache.get(cache_key)
698
+ if not isinstance(result, dict):
699
+ result = validate_research_material_template(
700
+ item,
701
+ template_profile,
702
+ max_chars=None,
703
+ max_bytes=int(policy["research_material_file_max_bytes"]),
704
+ disallowed_content_fingerprints=template_fingerprints,
705
+ )
706
+ cache[cache_key] = dict(result)
707
  if not bool(result.get("ok")):
708
  report["failures"].append(
709
  {
710
  "name": entry["name"],
711
+ "role": "research",
712
  "error": str(result.get("error") or "parse_failed"),
713
  }
714
  )
715
+ else:
716
+ chars = int(result.get("text_chars") or 0)
717
+ report["research_chars"] += chars
718
+ report["bundle_chars"] += len(f"### Source: {entry['name']}\n") + chars
719
+ successful_entries += 1
720
+
721
+ if successful_entries > 1:
722
+ report["bundle_chars"] += 2 * (successful_entries - 1)
723
 
 
724
  st.session_state["sp_material_parse_cache"] = {
725
  key: value for key, value in cache.items() if key in live_cache_keys
726
  }
727
 
728
  if report["failures"]:
729
  report["ok"] = False
730
+ if report["bundle_chars"] > bundle_limit:
731
+ report["ok"] = False
732
+ report["bundle_capacity_exceeded"] = True
733
  if (
734
  report["ok"]
735
  and counts["research"] > 0
 
740
  report["content_insufficient"] = True
741
  return report
742
 
743
+ def _material_bundle_text(entries: list[dict], research_bucket: str, template_profile: str) -> str:
744
+ if not entries:
 
 
745
  return ""
746
  policy = _research_material_upload_policy()
747
  if research_bucket == "data":
 
750
  total_budget = int(policy["qualitative_bundle_max_chars"])
751
  else:
752
  raise RuntimeError("method_material_routing_unsupported")
 
753
  sections = []
754
+ for entry in entries:
755
+ text = extract_research_material_content(
756
+ entry["item"],
757
+ template_profile,
758
+ max_chars=total_budget,
759
+ max_bytes=int(policy["research_material_file_max_bytes"]),
760
+ )
761
  sections.append(f"### Source: {entry['name']}\n{text.strip()}")
762
+ bundle = "\n\n".join(sections).strip()
763
+ if len(bundle) > total_budget:
764
+ raise RuntimeError("research_material_bundle_capacity_exceeded")
765
+ return bundle
766
 
767
  def _uploaded_material_descriptors() -> list[dict[str, str]]:
768
  out: list[dict[str, str]] = []
 
786
  "topic": _normalized_research_intent_text(
787
  st.session_state.get("sp_topic") or DEFAULT_TOPIC
788
  ),
789
+ "keywords": _canonical_user_keywords(
790
  st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS
791
  ),
792
  }
 
859
  )
860
  preflight = resolve_generation_preflight(
861
  str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
862
+ _canonical_user_keywords(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
863
  materials=_uploaded_material_descriptors(),
864
  confirmed_method_family=confirmed,
865
  )
 
868
  def _classify_and_save_research_materials(
869
  method_family: str, inputs_dir: Path
870
  ) -> tuple[list[str], list[str], list[str], str, list[dict]]:
871
+ """Persist template-compatible research materials for the selected design.
872
 
873
+ New tasks no longer accept uploaded reference literature. Research files are
874
+ combined into one deterministic text bundle so the frozen GenerationRunner
875
+ continues using its proven single empirical-input seam.
876
  """
877
  entries = _uploaded_material_entries()
878
  if not entries:
 
882
  if not report.get("ok"):
883
  raise RuntimeError("research_material_preflight_failed")
884
 
 
 
 
 
885
  original_research = _save_uploaded(
886
+ [entry["item"] for entry in entries], inputs_dir, "research"
887
  )
888
 
889
  data: list[str] = []
890
  interviews: list[str] = []
891
  capability = method_capability(method_family)
892
+ research_bucket = _explicit_material_bucket(method_family)
893
+ template_profile = str(capability.get("template_profile") or "").strip()
894
+ bundle_text = _material_bundle_text(entries, research_bucket, template_profile)
895
+ if not bundle_text:
896
+ raise RuntimeError("research_material_bundle_empty")
897
+ bundle_name = "research_materials_bundle.txt"
898
+ bundle_path = inputs_dir / bundle_name
899
+ bundle_path.write_text(bundle_text, encoding="utf-8")
900
+ bundle_rel = str(Path("inputs") / bundle_name)
901
+ if research_bucket == "data":
902
+ data = [bundle_rel]
903
+ else:
904
+ interviews = [bundle_rel]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
905
 
906
+ role_manifest = [
907
+ {
908
+ "name": entry["name"],
909
+ "role": "research",
910
+ "saved_path": original_research[index],
911
+ }
912
+ for index, entry in enumerate(entries)
913
+ ]
914
+ return [], data, interviews, "research_only_template_v2", role_manifest
915
 
916
  def _persist_task_request(
917
  task: dict,
 
951
  "target_lang": get_target_language(lang_code),
952
  "output_suffix": get_output_suffix(lang_code),
953
  "topic": str(st.session_state.get("sp_topic") or DEFAULT_TOPIC),
954
+ "keywords": _canonical_user_keywords(st.session_state.get("sp_keywords") or DEFAULT_KEYWORDS),
955
  "target_words": int(st.session_state.get("sp_words") or APP_CONFIG["target_words_default"]),
956
  # High-entropy, display-only token. It is part of the immutable task
957
  # request/hash, but it grants no Worker/D1 authority.
 
1227
 
1228
  inject_wechat_browser_notice()
1229
 
1230
+ DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning"
1231
+ DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized education; Educational technologies; Recommendation system;"
1232
 
1233
  def _current_language_code() -> str:
1234
  choice = st.session_state.get("interface_language_choice")
 
1848
  key="sp_keywords",
1849
  )
1850
 
1851
+ # P0-R7-E1 simplified workspace: Research Design -> Recommended Template ->
1852
+ # Research Materials. New tasks accept empirical materials only; reference
1853
+ # literature upload is intentionally removed from the product surface.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1854
  if active:
1855
  active_family = _task_method_family(task)
1856
  active_capability = method_capability(active_family)
 
1889
  if active:
1890
  st.markdown(f"**{tr(lang_code, 'detected_method_label')}:** {method_label}")
1891
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1892
  st.caption(tr(lang_code, "method_detection_explanation"))
1893
 
1894
  block_reason = str(preflight.get("block_reason") or "")
 
1918
  else:
1919
  st.markdown(tr(lang_code, "template_help_undetermined"))
1920
 
1921
+ allowed_extensions = (
1922
+ _research_material_extension_contract(method_family)
1923
+ if method_family in METHOD_FAMILIES
1924
+ else ()
1925
+ )
1926
+ uploader_help_key = (
1927
+ f"research_materials_help_{method_family}"
1928
+ if method_family in METHOD_FAMILIES
1929
+ else "research_materials_help_undetermined"
1930
+ )
1931
+ uploaded_widget = st.file_uploader(
1932
+ tr(lang_code, "research_materials_label"),
1933
+ type=list(allowed_extensions),
1934
+ accept_multiple_files=True,
1935
+ key=f"up_research_materials_{method_family}",
1936
+ disabled=active or not allowed_extensions,
1937
+ help=tr(lang_code, uploader_help_key),
1938
+ )
1939
+ # Canonical non-widget session projection used by request freeze/preflight.
1940
+ st.session_state["up_research_materials"] = list(uploaded_widget or [])
1941
+
1942
+ material_entries = _uploaded_material_entries()
1943
+ limits_ok, material_counts, material_policy = _material_limits_ok(material_entries)
1944
+ if material_counts["research"] > material_policy["research_material_file_limit"]:
1945
+ st.error(
1946
+ tr(
1947
+ lang_code,
1948
+ "material_research_limit_exceeded",
1949
+ limit=material_policy["research_material_file_limit"],
1950
+ )
1951
+ )
1952
+
1953
+ material_gate = {"ok": True, "failures": []}
1954
+ if not active and limits_ok:
1955
+ material_gate = _material_preflight_report(method_family, material_entries)
1956
+ expected_format = (material_gate.get("expected_extensions") or [""])[0].upper()
1957
+ for failure in material_gate.get("failures") or []:
1958
+ error_code = str(failure.get("error") or "")
1959
+ if error_code.startswith("research_material_file_too_large"):
1960
+ st.error(
1961
+ tr(
1962
+ lang_code,
1963
+ "material_file_size_exceeded",
1964
+ filename=str(failure.get("name") or ""),
1965
+ limit_mb=max(1, int(material_policy["research_material_file_max_bytes"]) // (1024 * 1024)),
1966
+ )
1967
+ )
1968
+ elif error_code == "template_example_not_replaced":
1969
+ st.error(
1970
+ tr(
1971
+ lang_code,
1972
+ "material_template_example_not_replaced",
1973
+ filename=str(failure.get("name") or ""),
1974
+ )
1975
+ )
1976
+ else:
1977
+ st.error(
1978
+ tr(
1979
+ lang_code,
1980
+ "material_template_parse_failed",
1981
+ filename=str(failure.get("name") or ""),
1982
+ format=expected_format,
1983
+ )
1984
+ )
1985
+ if material_gate.get("total_size_exceeded"):
1986
+ st.error(
1987
+ tr(
1988
+ lang_code,
1989
+ "material_total_size_exceeded",
1990
+ limit_mb=max(1, int(material_policy["research_material_total_max_bytes"]) // (1024 * 1024)),
1991
+ )
1992
+ )
1993
+ if material_gate.get("bundle_capacity_exceeded"):
1994
+ st.error(tr(lang_code, "material_bundle_capacity_exceeded"))
1995
+ if material_gate.get("content_insufficient"):
1996
+ st.error(tr(lang_code, "material_research_content_insufficient"))
1997
+ elif not active:
1998
+ material_gate = {"ok": False, "failures": []}
1999
+
2000
  product_ui = get_product_ui_projection()
2001
  product_ui_ready = product_ui.get("mode") == "a5_product"
2002
  selected_remaining: int | None = None
config.py CHANGED
@@ -68,21 +68,28 @@ 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
- # 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(
@@ -97,7 +104,6 @@ def load_config():
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
 
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-E1: research-material-only upload policy. File type is owned
72
+ # by the selected Research Design capability; app.py owns no format or
73
+ # count business literals.
74
  "research_material_upload_policy": {
75
  "version": os.environ.get(
76
  "SMARTPAPER_RESEARCH_MATERIAL_UPLOAD_POLICY_VERSION",
77
+ "research-material-upload-policy-v2-template-strict",
78
+ ).strip() or "research-material-upload-policy-v2-template-strict",
 
 
 
 
79
  "research_material_file_limit": max(
80
  1,
81
  min(100, int(os.environ.get("SMARTPAPER_RESEARCH_MATERIAL_FILE_LIMIT", "10"))),
82
  ),
83
+ # P0-R7-E1-R2: application-level byte gates are policy-driven and
84
+ # evaluated before any strict parser reads user content.
85
+ "research_material_file_max_bytes": max(
86
+ 1024,
87
+ int(os.environ.get("SMARTPAPER_RESEARCH_MATERIAL_FILE_MAX_BYTES", str(2 * 1024 * 1024))),
88
+ ),
89
+ "research_material_total_max_bytes": max(
90
+ 1024,
91
+ int(os.environ.get("SMARTPAPER_RESEARCH_MATERIAL_TOTAL_MAX_BYTES", str(10 * 1024 * 1024))),
92
+ ),
93
  # Preserve the existing qualitative engine's >100-character
94
  # user-evidence threshold without duplicating it in UI code.
95
  "qualitative_min_combined_chars": max(
 
104
  1000,
105
  int(os.environ.get("SMARTPAPER_QUALITATIVE_RESEARCH_BUNDLE_MAX_CHARS", os.environ.get("MAX_EXTRACT_CHARS", "150000"))),
106
  ),
 
107
  },
108
  # Model requests may legitimately run longer than one Worker lease.
109
  # The independent Executor renews authority continuously; model-token
core/advanced_method_registry.py CHANGED
@@ -34,6 +34,7 @@ _METHOD_CAPABILITIES: dict[str, dict[str, Any]] = {
34
  "engine_family": "qualitative_engine",
35
  "template_profile": "qualitative_materials",
36
  "material_profile": "qualitative_materials",
 
37
  "material_routing": {
38
  "structured": "data",
39
  "text": "interview",
@@ -51,6 +52,7 @@ _METHOD_CAPABILITIES: dict[str, dict[str, Any]] = {
51
  "engine_family": "quantitative_engine",
52
  "template_profile": "survey_quantitative_data",
53
  "material_profile": "survey_quantitative_data",
 
54
  "material_routing": {
55
  "structured": "data",
56
  "text": "reference",
@@ -342,6 +344,11 @@ def resolve_generation_preflight(
342
  engine_family = str(capability.get("engine_family") or "").strip()
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",
@@ -355,6 +362,8 @@ def resolve_generation_preflight(
355
  or not engine_family
356
  or not template_profile
357
  or not material_profile
 
 
358
  or set(material_routing) != routing_keys
359
  or not routing_values.issubset({"reference", "data", "interview"})
360
  ):
 
34
  "engine_family": "qualitative_engine",
35
  "template_profile": "qualitative_materials",
36
  "material_profile": "qualitative_materials",
37
+ "research_material_extensions": ("txt",),
38
  "material_routing": {
39
  "structured": "data",
40
  "text": "interview",
 
52
  "engine_family": "quantitative_engine",
53
  "template_profile": "survey_quantitative_data",
54
  "material_profile": "survey_quantitative_data",
55
+ "research_material_extensions": ("csv",),
56
  "material_routing": {
57
  "structured": "data",
58
  "text": "reference",
 
344
  engine_family = str(capability.get("engine_family") or "").strip()
345
  template_profile = str(capability.get("template_profile") or "").strip()
346
  material_profile = str(capability.get("material_profile") or "").strip()
347
+ research_material_extensions = tuple(
348
+ str(item or "").strip().casefold()
349
+ for item in (capability.get("research_material_extensions") or ())
350
+ if str(item or "").strip()
351
+ )
352
  material_routing = dict(capability.get("material_routing") or {})
353
  routing_keys = {
354
  "structured", "text", "document_reference", "document_research",
 
362
  or not engine_family
363
  or not template_profile
364
  or not material_profile
365
+ or len(research_material_extensions) != 1
366
+ or research_material_extensions[0] not in {"txt", "csv"}
367
  or set(material_routing) != routing_keys
368
  or not routing_values.issubset({"reference", "data", "interview"})
369
  ):
core/qualitative_engine.py CHANGED
@@ -664,6 +664,23 @@ Rules:
664
  {schema}
665
  """
666
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
  def _qualitative_packet_validation_error(self, data, *, has_user_data, user_intent=None):
668
  """Return a bounded local validation reason, or an empty string when safe.
669
 
@@ -739,7 +756,7 @@ Rules:
739
  continue
740
  quote = self._clean_cell(item.get("quote"), "").replace('"', "'")
741
  normalized_quote = re.sub(r"\s+", " ", quote).strip().casefold()
742
- if not quote or len(quote.split()) < 4 or normalized_quote in seen_quotes:
743
  continue
744
  seen_quotes.add(normalized_quote)
745
  retained += 1
@@ -783,12 +800,13 @@ Rules:
783
  else:
784
  retry_prompt = base_prompt + (
785
  "\n\nLOCAL VALIDATION RETRY: The previous completed response failed local "
786
- "JSON/schema/content validation. Generate a fresh complete qualitative evidence "
787
- "packet from the ORIGINAL inputs above. Do not repair, continue, quote, or rely on "
788
- "the previous response. Return exactly one complete JSON object matching the required "
789
- "schema, with no Markdown or explanatory prose. Preserve every evidence-fidelity rule "
790
- "from the original request, including verbatim quotation requirements when user material "
791
- "was supplied."
 
792
  )
793
  response = self.agent.call_deepseek(
794
  system_prompt,
@@ -917,7 +935,7 @@ Rules:
917
  if evidence_role not in {"typical", "deviant"}:
918
  evidence_role = "typical"
919
  normalized_quote = re.sub(r"\s+", " ", quote).strip().lower()
920
- if not quote or len(quote.split()) < 4 or normalized_quote in seen_quotes:
921
  continue
922
  seen_quotes.add(normalized_quote)
923
  tag = f"QUOTE-{len(clean_evidence) + 1:02d}"
 
664
  {schema}
665
  """
666
 
667
+ @staticmethod
668
+ def _quote_has_substantive_content(value):
669
+ """Language-aware minimum-content gate for participant quotations.
670
+
671
+ The previous whitespace-token rule rejected ordinary Chinese sentences
672
+ because an entire CJK sentence can contain zero spaces. Article Agent
673
+ supports Chinese/English/Russian/Kazakh, so accept either four or more
674
+ whitespace-delimited tokens or a substantive CJK character sequence.
675
+ """
676
+ quote = re.sub(r"\s+", " ", str(value or "")).strip()
677
+ if not quote:
678
+ return False
679
+ if len(quote.split()) >= 4:
680
+ return True
681
+ cjk_chars = re.findall(r"[\u3400-\u4dbf\u4e00-\u9fff]", quote)
682
+ return len(cjk_chars) >= 8
683
+
684
  def _qualitative_packet_validation_error(self, data, *, has_user_data, user_intent=None):
685
  """Return a bounded local validation reason, or an empty string when safe.
686
 
 
756
  continue
757
  quote = self._clean_cell(item.get("quote"), "").replace('"', "'")
758
  normalized_quote = re.sub(r"\s+", " ", quote).strip().casefold()
759
+ if not self._quote_has_substantive_content(quote) or normalized_quote in seen_quotes:
760
  continue
761
  seen_quotes.add(normalized_quote)
762
  retained += 1
 
800
  else:
801
  retry_prompt = base_prompt + (
802
  "\n\nLOCAL VALIDATION RETRY: The previous completed response failed local "
803
+ f"JSON/schema/content validation with reason={last_reason}. Generate a fresh complete "
804
+ "qualitative evidence packet from the ORIGINAL inputs above. Do not repair, continue, "
805
+ "quote, or rely on the previous response. Return exactly one complete JSON object matching "
806
+ "the required schema, with no Markdown or explanatory prose. The evidence array must contain "
807
+ "the required complete evidence objects, each with non-empty theme, concept, quote, stage, "
808
+ "case_label, and evidence_role fields. Preserve every evidence-fidelity rule from the original "
809
+ "request, including verbatim quotation requirements when user material was supplied."
810
  )
811
  response = self.agent.call_deepseek(
812
  system_prompt,
 
935
  if evidence_role not in {"typical", "deviant"}:
936
  evidence_role = "typical"
937
  normalized_quote = re.sub(r"\s+", " ", quote).strip().lower()
938
+ if not self._quote_has_substantive_content(quote) or normalized_quote in seen_quotes:
939
  continue
940
  seen_quotes.add(normalized_quote)
941
  tag = f"QUOTE-{len(clean_evidence) + 1:02d}"
i18n.py CHANGED
@@ -277,21 +277,29 @@ TEXTS: dict[str, dict[str, str]] = {
277
  "asq_hint": "**⚠️ 核心提示**:为了让AI准确提取语录和编码树,请务必标注**受访者角色(Role)**,并采用清晰的**对话格式**。",
278
  "download_asq_template": "📥 一键下载 TXT 访谈模板",
279
  "start_asq": "🚀 启动 ASQ 模式生成",
280
- "research_materials_label": "研究资料(可选)",
281
- "research_materials_help": "支持 PDF、TXT、DOC、DOCX、CSV、XLS、XLSX。上传资料用于辅助论文生成。研究设计默认为质性研究,可根据需要调整为问卷量化研究。",
 
 
 
282
  "drop_research_materials": "将研究资料拖到这里",
283
- "limit_research_materials": "每个文件最大 1 GB · PDF, TXT, DOC, DOCX, CSV, XLS, XLSX",
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": "调整研究设计",
297
  "method_confirm_placeholder": "请选择研究设计",
@@ -532,21 +540,29 @@ TEXTS: dict[str, dict[str, str]] = {
532
  "asq_hint": "**⚠️ Important**: Clearly label each **informant role** and use a consistent **dialogue format** so the system can accurately extract quotations and build the coding structure.",
533
  "download_asq_template": "📥 Download TXT Interview Template",
534
  "start_asq": "🚀 Start ASQ Generation",
535
- "research_materials_label": "Research Materials (Optional)",
536
- "research_materials_help": "Supports PDF, TXT, DOC, DOCX, CSV, XLS, and XLSX. Uploaded files support paper generation. Research Design defaults to Qualitative Research and can be adjusted to Survey-Based Quantitative Research.",
 
 
 
537
  "drop_research_materials": "Drag research materials here",
538
- "limit_research_materials": "Up to 1 GB per file · PDF, TXT, DOC, DOCX, CSV, XLS, XLSX",
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",
552
  "method_confirm_placeholder": "Select a Research Design",
@@ -787,21 +803,29 @@ TEXTS: dict[str, dict[str, str]] = {
787
  "asq_hint": "**⚠️ Важно**: укажите **роль информанта** и соблюдайте единый **формат диалога**, чтобы система точно извлекала цитаты и строила структуру кодирования.",
788
  "download_asq_template": "📥 Скачать TXT-шаблон интервью",
789
  "start_asq": "🚀 Запустить формирование в режиме ASQ",
790
- "research_materials_label": "Материалы исследования (необязательно)",
791
- "research_materials_help": "Поддерживаются PDF, TXT, DOC, DOCX, CSV, XLS и XLSX. Загруженные файлы используются как дополнительные исследовательские или справочные материалы. По умолчанию выбрано качественное исследование; при необходимости дизайн можно изменить на количественное исследование на основе опроса.",
 
 
 
792
  "drop_research_materials": "Перетащите исследовательские материалы сюда",
793
- "limit_research_materials": "До 1 ГБ на файл · PDF, TXT, DOC, DOCX, CSV, XLS, XLSX",
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": "Изменить дизайн исследования",
807
  "method_confirm_placeholder": "Выберите дизайн исследования",
@@ -1042,21 +1066,29 @@ TEXTS: dict[str, dict[str, str]] = {
1042
  "asq_hint": "**⚠️ Маңызды**: жүйе дәйексөздерді дәл шығарып, кодтау құрылымын құруы үшін **ақпарат берушінің рөлін** көрсетіп, бірізді **диалог пішімін** қолданыңыз.",
1043
  "download_asq_template": "📥 TXT сұхбат үлгісін жүктеу",
1044
  "start_asq": "🚀 ASQ режимінде дайындауды бастау",
1045
- "research_materials_label": "Зерттеу материалдары (міндетті емес)",
1046
- "research_materials_help": "PDF, TXT, DOC, DOCX, CSV, XLS және XLSX форматтары қолдау табады. Жүктелген файлдар қосымша зерттеу немесе анықтамалық материал ретінде пайдаланылады. Әдепкі бойынша сапалық зерттеу таңдалады; қажет болса, дизайнды сауалнамаға негізделген сандық зерттеуге өзгертуге болады.",
 
 
 
1047
  "drop_research_materials": "Зерттеу материалдарын осы жерге сүйреп әкеліңіз",
1048
- "limit_research_materials": "Әр файлға 1 ГБ дейін · PDF, TXT, DOC, DOCX, CSV, XLS, XLSX",
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": "Зерттеу дизайнын өзгерту",
1062
  "method_confirm_placeholder": "Зерттеу дизайнын таңдаңыз",
 
277
  "asq_hint": "**⚠️ 核心提示**:为了让AI准确提取语录和编码树,请务必标注**受访者角色(Role)**,并采用清晰的**对话格式**。",
278
  "download_asq_template": "📥 一键下载 TXT 访谈模板",
279
  "start_asq": "🚀 启动 ASQ 模式生成",
280
+ "research_materials_label": "📂 研究资料(可选)",
281
+ "research_materials_help": "研究资料必须使用上方推荐模板。",
282
+ "research_materials_help_qualitative": "质性研究仅接受 TXT,且内容必须符合上方推荐模板。",
283
+ "research_materials_help_survey_path": "问卷量化研究仅接受 CSV,且内容必须符合上方推荐模板。",
284
+ "research_materials_help_undetermined": "请先确认研究设计。",
285
  "drop_research_materials": "将研究资料拖到这里",
286
+ "limit_research_materials": "每个文件最大 1 GB · 仅接受当前研究设计对应模板格式",
287
  "material_document_use_label": "如何使用上传的 PDF/DOC/DOCX 文件?",
288
  "material_use_reference": "作为参考文献资料",
289
  "material_use_research": "作为研究资料",
290
  "material_reference_limit_exceeded": "参考文献最多可选择 {limit} 个,请调整文件用途或删除多余文件。",
291
  "material_research_limit_exceeded": "研究资料最多可选择 {limit} 个,请调整文件用途或删除多余文件。",
292
  "material_parse_failed": "文件“{filename}”无法被系统正确解析。请先转换为可识别格式后重新上传(建议:{formats})。",
293
+ "material_template_parse_failed": "研究资料“{filename}”无法按推荐模板正确识别。请使用上方推荐模板整理为 {format} 文件后重新上传。",
294
  "material_reference_convert_formats": "PDF、DOCX 或 TXT",
295
  "material_research_convert_formats": "PDF、DOCX、TXT、CSV 或 XLSX",
296
  "material_research_content_insufficient": "已选择的研究资料可解析内容不足,系统无法可靠识别。请补充内容或转换为可正确识别的格式后重新上传。",
297
+ "material_file_size_exceeded": "研究资料“{filename}”文件过大。单个文件不得超过 {limit_mb} MB,请精简后重新上传。",
298
+ "material_total_size_exceeded": "研究资料总大小超过 {limit_mb} MB,请减少文件数量或精简内容后重新上传。",
299
+ "material_bundle_capacity_exceeded": "研究资料内容超过当前研究设计可可靠处理的容量。请精简材料后重新上传,系统不会静默截断。",
300
+ "material_template_example_not_replaced": "研究资料“{filename}”仍是未修改的推荐模板示例。请用真实研究结果替换示例内容后重新上传。",
301
  "material_preflight_blocked": "研究资料预检未通过,请先处理上方文件问题;系统尚未创建任务或预留额度。",
302
+ "detected_method_label": "🧭 研究设计(可调整)",
303
  "method_detection_explanation": "默认为质性研究;生成前可根据需要调整为问卷量化研究。",
304
  "method_confirm_label": "调整研究设计",
305
  "method_confirm_placeholder": "请选择研究设计",
 
540
  "asq_hint": "**⚠️ Important**: Clearly label each **informant role** and use a consistent **dialogue format** so the system can accurately extract quotations and build the coding structure.",
541
  "download_asq_template": "📥 Download TXT Interview Template",
542
  "start_asq": "🚀 Start ASQ Generation",
543
+ "research_materials_label": "📂 Research Materials (Optional)",
544
+ "research_materials_help": "Research materials must follow the Recommended Template above.",
545
+ "research_materials_help_qualitative": "Qualitative Research accepts TXT only, following the Recommended Template above.",
546
+ "research_materials_help_survey_path": "Survey-Based Quantitative Research accepts CSV only, following the Recommended Template above.",
547
+ "research_materials_help_undetermined": "Confirm the Research Design first.",
548
  "drop_research_materials": "Drag research materials here",
549
+ "limit_research_materials": "Up to 1 GB per file · only the template format for the current Research Design",
550
  "material_document_use_label": "How should SmartPaper use the uploaded PDF/DOC/DOCX files?",
551
  "material_use_reference": "Use as reference literature",
552
  "material_use_research": "Use as research materials",
553
  "material_reference_limit_exceeded": "You can select at most {limit} reference files. Change file roles or remove extra files.",
554
  "material_research_limit_exceeded": "You can select at most {limit} research-material files. Change file roles or remove extra files.",
555
  "material_parse_failed": "The system cannot reliably parse “{filename}”. Convert it to a supported readable format and upload it again (recommended: {formats}).",
556
+ "material_template_parse_failed": "Research material “{filename}” does not match the Recommended Template. Reformat it as a {format} file using the template above and upload it again.",
557
  "material_reference_convert_formats": "PDF, DOCX, or TXT",
558
  "material_research_convert_formats": "PDF, DOCX, TXT, CSV, or XLSX",
559
  "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.",
560
+ "material_file_size_exceeded": "Research material “{filename}” is too large. Each file must be no larger than {limit_mb} MB. Reduce it and upload again.",
561
+ "material_total_size_exceeded": "The total research-material size exceeds {limit_mb} MB. Reduce the number or size of files and upload again.",
562
+ "material_bundle_capacity_exceeded": "The research materials exceed the reliable processing capacity for this research design. Reduce the materials and upload again; the system will not silently truncate them.",
563
+ "material_template_example_not_replaced": "Research material “{filename}” is still the unchanged Recommended Template example. Replace the sample values with your actual research results and upload again.",
564
  "material_preflight_blocked": "Research-material preflight did not pass. Resolve the file issues above first; no task or quota reservation has been created.",
565
+ "detected_method_label": "🧭 Research Design (Adjustable)",
566
  "method_detection_explanation": "Qualitative Research is selected by default. Before generation, you can adjust the Research Design to Survey-Based Quantitative Research.",
567
  "method_confirm_label": "Adjust Research Design",
568
  "method_confirm_placeholder": "Select a Research Design",
 
803
  "asq_hint": "**⚠️ Важно**: укажите **роль информанта** и соблюдайте единый **формат диалога**, чтобы система точно извлекала цитаты и строила структуру кодирования.",
804
  "download_asq_template": "📥 Скачать TXT-шаблон интервью",
805
  "start_asq": "🚀 Запустить формирование в режиме ASQ",
806
+ "research_materials_label": "📂 Материалы исследования (необязательно)",
807
+ "research_materials_help": "Материалы исследования должны соответствовать рекомендуемому шаблону выше.",
808
+ "research_materials_help_qualitative": "Для качественного исследования принимаются только TXT-файлы по рекомендуемому шаблону выше.",
809
+ "research_materials_help_survey_path": "Для количественного исследования на основе опроса принимаются только CSV-файлы по рекомендуемому шаблону выше.",
810
+ "research_materials_help_undetermined": "Сначала подтвердите дизайн исследования.",
811
  "drop_research_materials": "Перетащите исследовательские материалы сюда",
812
+ "limit_research_materials": "До 1 ГБ на файл · только формат шаблона текущего дизайна исследования",
813
  "material_document_use_label": "Как использовать загруженные файлы PDF/DOC/DOCX?",
814
  "material_use_reference": "Как научные источники",
815
  "material_use_research": "Как исследовательские материалы",
816
  "material_reference_limit_exceeded": "Можно выбрать не более {limit} файлов в качестве научных источников. Измените назначение файлов или удалите лишние.",
817
  "material_research_limit_exceeded": "Можно выбрать не более {limit} файлов в качестве исследовательских материалов. Измените назначение файлов или удалите лишние.",
818
  "material_parse_failed": "Система не может корректно распознать файл «{filename}». Преобразуйте его в поддерживаемый читаемый формат и загрузите снова (рекомендуется: {formats}).",
819
+ "material_template_parse_failed": "Материал «{filename}» не соответствует рекомендуемому шаблону. Оформите его по шаблону выше как файл {format} и загрузите снова.",
820
  "material_reference_convert_formats": "PDF, DOCX или TXT",
821
  "material_research_convert_formats": "PDF, DOCX, TXT, CSV или XLSX",
822
  "material_research_content_insufficient": "В выбранных исследовательских материалах недостаточно корректно распознаваемого содержимого. Дополните материалы или преобразуйте файлы в читаемый формат и загрузите их снова.",
823
+ "material_file_size_exceeded": "Файл исследовательского материала «{filename}» слишком велик. Размер одного файла не должен превышать {limit_mb} МБ. Сократите его и загрузите снова.",
824
+ "material_total_size_exceeded": "Общий размер исследовательских материалов превышает {limit_mb} МБ. Уменьшите количество или размер файлов и загрузите снова.",
825
+ "material_bundle_capacity_exceeded": "Объём исследовательских материалов превышает надёжную ёмкость обработки для выбранного дизайна. Сократите материалы и загрузите снова; система не будет молча обрезать их.",
826
+ "material_template_example_not_replaced": "Материал «{filename}» всё ещё содержит неизменённый пример рекомендуемого шаблона. Замените пример своими фактическими результатами и загрузите файл снова.",
827
  "material_preflight_blocked": "Предварительная проверка исследовательских материалов не пройдена. Сначала устраните указанные выше проблемы; задача и резерв квоты ещё не созданы.",
828
+ "detected_method_label": "🧭 Дизайн исследования (можно изменить)",
829
  "method_detection_explanation": "По умолчанию выбрано качественное исследование. До начала генерации дизайн можно изменить на количественное исследование на основе опроса.",
830
  "method_confirm_label": "Изменить дизайн исследования",
831
  "method_confirm_placeholder": "Выберите дизайн исследования",
 
1066
  "asq_hint": "**⚠️ Маңызды**: жүйе дәйексөздерді дәл шығарып, кодтау құрылымын құруы үшін **ақпарат берушінің рөлін** көрсетіп, бірізді **диалог пішімін** қолданыңыз.",
1067
  "download_asq_template": "📥 TXT сұхбат үлгісін жүктеу",
1068
  "start_asq": "🚀 ASQ режимінде дайындауды бастау",
1069
+ "research_materials_label": "📂 Зерттеу материалдары (міндетті емес)",
1070
+ "research_materials_help": "Зерттеу материалдары жоғарыдағы ұсынылатын үлгіге сәйкес болуы тиіс.",
1071
+ "research_materials_help_qualitative": "Сапалық зерттеу үшін жоғарыдағы ұсынылатын үлгіге сәйкес тек TXT файлы қабылданады.",
1072
+ "research_materials_help_survey_path": "Сауалнамаға негізделген сандық зерттеу үшін жоғарыдағы ұсынылатын үлгіге сәйкес тек CSV файлы қабылданады.",
1073
+ "research_materials_help_undetermined": "Алдымен зерттеу дизайнын растаңыз.",
1074
  "drop_research_materials": "Зерттеу материалдарын осы жерге сүйреп әкеліңіз",
1075
+ "limit_research_materials": "Әр файлға 1 ГБ дейін · тек ағымдағы зерттеу дизайнының үлгі форматы",
1076
  "material_document_use_label": "Жүктелген PDF/DOC/DOCX файлдарын қалай пайдалану керек?",
1077
  "material_use_reference": "Ғылыми дереккөз ретінде",
1078
  "material_use_research": "Зерттеу материалы ретінде",
1079
  "material_reference_limit_exceeded": "Ғылыми дереккөз ретінде ең көбі {limit} файл таңдауға болады. Файлдардың мақсатын өзгертіңіз немесе артық файлдарды жойыңыз.",
1080
  "material_research_limit_exceeded": "Зерттеу материалы ретінде ең көбі {limit} файл таңдауға болады. Файлдардың мақсатын өзгертіңіз немесе артық файлдарды жойыңыз.",
1081
  "material_parse_failed": "Жүйе «{filename}» файлын сенімді түрде тани алмады. Оны оқылатын қолдау көрсетілетін пішімге түрлендіріп, қайта жүктеңіз (ұсынылады: {formats}).",
1082
+ "material_template_parse_failed": "«{filename}» зерттеу материалы ұсынылатын үлгіге сәйкес емес. Оны жоғарыдағы үлгімен {format} файлы ретінде қайта рәсімдеп, қайта жүктеңіз.",
1083
  "material_reference_convert_formats": "PDF, DOCX немесе TXT",
1084
  "material_research_convert_formats": "PDF, DOCX, TXT, CSV немесе XLSX",
1085
  "material_research_content_insufficient": "Таңдалған зерттеу материалдарында сенімді пайдалануға жеткілікті танылатын мазмұн жоқ. Мазмұнды толықтырыңыз немесе файлдарды оқылатын пішімге түрлендіріп, қайта жүктеңіз.",
1086
+ "material_file_size_exceeded": "«{filename}» зерттеу материалының файлы тым үлкен. Бір файл {limit_mb} МБ-тан аспауы тиіс. Қысқартып, қайта жүктеңіз.",
1087
+ "material_total_size_exceeded": "Зерттеу материалдарының жалпы көлемі {limit_mb} МБ-тан асады. Файл санын немесе көлемін азайтып, қайта жүктеңіз.",
1088
+ "material_bundle_capacity_exceeded": "Зерттеу материалдарының көлемі таңдалған дизайн үшін сенімді өңдеу шегінен асады. Материалдарды қысқартып, қайта жүктеңіз; жүйе оларды үнсіз қысқартпайды.",
1089
+ "material_template_example_not_replaced": "«{filename}» материалы әлі де өзгертілмеген ұсынылатын үлгі мысалы болып тұр. Үлгі мәндерін нақты зерттеу нәтижелеріңізбен ауыстырып, қайта жүктеңіз.",
1090
  "material_preflight_blocked": "Зерттеу материалдарының алдын ала тексеруі өтпеді. Алдымен жоғарыдағы файл мәселелерін түзетіңіз; тапсырма да, квота резерві де әлі жасалған жоқ.",
1091
+ "detected_method_label": "🧭 Зерттеу дизайны (өзгертуге болады)",
1092
  "method_detection_explanation": "Әдепкі бойынша сапалық зерттеу таңдалады. Генерацияны бастамас бұрын зерттеу дизайнын сауалнамаға негізделген сандық зерттеуге өзгертуге болады.",
1093
  "method_confirm_label": "Зерттеу дизайнын өзгерту",
1094
  "method_confirm_placeholder": "Зерттеу дизайнын таңдаңыз",
utils/file_parser.py CHANGED
@@ -6,9 +6,11 @@ an explicit validator that the UI can run before task creation/quota reservation
6
  """
7
  from __future__ import annotations
8
 
 
9
  import io
10
  import logging
11
  import os
 
12
  from pathlib import Path
13
  from typing import BinaryIO, Iterable
14
 
@@ -187,6 +189,152 @@ def validate_file_parseable(file, max_chars=4000) -> dict:
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)
 
6
  """
7
  from __future__ import annotations
8
 
9
+ import hashlib
10
  import io
11
  import logging
12
  import os
13
+ import re
14
  from pathlib import Path
15
  from typing import BinaryIO, Iterable
16
 
 
189
  }
190
 
191
 
192
+
193
+
194
+ _STRICT_RESEARCH_TEMPLATE_EXTENSIONS = {
195
+ "qualitative_materials": ".txt",
196
+ "survey_quantitative_data": ".csv",
197
+ }
198
+
199
+
200
+ def _validate_qualitative_template_text(text: str) -> None:
201
+ value = str(text or "").strip()
202
+ if not value:
203
+ raise FileParseError("template_content_empty")
204
+ metadata_lines = re.findall(r"(?m)^\s*\[[^\]\n]{2,100}\]\s*[::]\s*\S+", value)
205
+ dialogue_lines = [
206
+ line.strip()
207
+ for line in value.splitlines()
208
+ if line.strip() and not line.lstrip().startswith("[") and re.search(r"[::]", line)
209
+ ]
210
+ if not re.search(r"\bINT-\d+\b", value, flags=re.I):
211
+ raise FileParseError("template_missing_interview_id")
212
+ if len(metadata_lines) < 3 or len(dialogue_lines) < 2:
213
+ raise FileParseError("template_structure_mismatch")
214
+ # The downloaded template contains explicit placeholders. If any remain,
215
+ # the file is not yet research material and must not enter generation.
216
+ if re.search(r"\[(?:在这里输入|Enter\b|Введите\b|[^\]\n]*осында енгізіңіз)", value, flags=re.I):
217
+ raise FileParseError("template_placeholders_not_replaced")
218
+
219
+
220
+ def _validate_survey_template_text(text: str) -> None:
221
+ value = str(text or "").strip()
222
+ if not value:
223
+ raise FileParseError("template_content_empty")
224
+ lines = [line.strip() for line in value.splitlines() if line.strip()]
225
+ section_markers = [line for line in lines if line.startswith("---") and line.endswith("---")]
226
+ has_descriptive = any(",SD," in line and line.count(",") >= 4 for line in lines)
227
+ has_reliability = any("Cronbach's Alpha" in line and ",CR," in line and ",AVE" in line for line in lines)
228
+ has_hypothesis = any("Beta" in line and ("P-value" in line or "P-значение" in line or "P-мәні" in line) for line in lines)
229
+ if len(section_markers) < 3 or not (has_descriptive and has_reliability and has_hypothesis):
230
+ raise FileParseError("template_structure_mismatch")
231
+
232
+
233
+ def _research_material_size_hint(file) -> int | None:
234
+ if isinstance(file, (str, os.PathLike, Path)):
235
+ try:
236
+ return int(Path(file).stat().st_size)
237
+ except OSError:
238
+ return None
239
+ value = getattr(file, "size", None)
240
+ try:
241
+ if value is not None:
242
+ return int(value)
243
+ except (TypeError, ValueError):
244
+ pass
245
+ try:
246
+ return int(file.getbuffer().nbytes)
247
+ except Exception:
248
+ return None
249
+
250
+
251
+ def research_material_text_fingerprint(text: str) -> str:
252
+ """Stable fingerprint for detecting an unchanged downloadable template."""
253
+ normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
254
+ normalized = "\n".join(line.rstrip() for line in normalized.splitlines()).strip()
255
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
256
+
257
+
258
+ def _strict_research_material_text(file, template_profile: str, *, max_bytes: int | None = None) -> tuple[str, int]:
259
+ profile = str(template_profile or "").strip().casefold()
260
+ expected_ext = _STRICT_RESEARCH_TEMPLATE_EXTENSIONS.get(profile)
261
+ if not expected_ext:
262
+ raise FileParseError("template_profile_unsupported")
263
+ filename = _name(file)
264
+ suffix = Path(filename).suffix.casefold()
265
+ if suffix != expected_ext:
266
+ raise FileParseError(f"template_extension_mismatch:expected={expected_ext}")
267
+ byte_limit = int(max_bytes or 0)
268
+ size_hint = _research_material_size_hint(file)
269
+ if byte_limit > 0 and size_hint is not None and size_hint > byte_limit:
270
+ raise FileParseError(f"research_material_file_too_large:{size_hint}:{byte_limit}")
271
+ try:
272
+ raw = _read_bytes(file)
273
+ if byte_limit > 0 and len(raw) > byte_limit:
274
+ raise FileParseError(f"research_material_file_too_large:{len(raw)}:{byte_limit}")
275
+ value = _decode_text_bytes(raw).strip()
276
+ finally:
277
+ if not isinstance(file, (str, os.PathLike, Path)):
278
+ try:
279
+ file.seek(0)
280
+ except Exception:
281
+ pass
282
+ if profile == "qualitative_materials":
283
+ _validate_qualitative_template_text(value)
284
+ elif profile == "survey_quantitative_data":
285
+ _validate_survey_template_text(value)
286
+ return value, len(raw)
287
+
288
+
289
+ def extract_research_material_content(file, template_profile: str, max_chars=None, *, max_bytes: int | None = None) -> str:
290
+ """Read one strict research-material file under the selected template contract."""
291
+ value, _byte_size = _strict_research_material_text(file, template_profile, max_bytes=max_bytes)
292
+ if max_chars is None:
293
+ max_chars = APP_CONFIG.get("max_extract_chars", 150000)
294
+ max_chars = max(1, int(max_chars))
295
+ return value[:max_chars]
296
+
297
+
298
+ def validate_research_material_template(
299
+ file,
300
+ template_profile: str,
301
+ max_chars=None,
302
+ *,
303
+ max_bytes: int | None = None,
304
+ disallowed_content_fingerprints: set[str] | None = None,
305
+ ) -> dict:
306
+ """Return an exact, UI-safe strict-template validation result.
307
+
308
+ Validation counts the full decoded text before any bundle truncation, so the
309
+ caller can reject oversized bundles before task creation/quota reservation.
310
+ """
311
+ filename = os.path.basename(_name(file))
312
+ try:
313
+ content, byte_size = _strict_research_material_text(file, template_profile, max_bytes=max_bytes)
314
+ fingerprint = research_material_text_fingerprint(content)
315
+ if disallowed_content_fingerprints and fingerprint in disallowed_content_fingerprints:
316
+ raise FileParseError("template_example_not_replaced")
317
+ shown = content if max_chars is None else content[: max(1, int(max_chars))]
318
+ return {
319
+ "ok": True,
320
+ "filename": filename,
321
+ "text_chars": len(content),
322
+ "preview_chars": len(shown),
323
+ "byte_size": byte_size,
324
+ "fingerprint": fingerprint,
325
+ "error": "",
326
+ }
327
+ except Exception as exc:
328
+ return {
329
+ "ok": False,
330
+ "filename": filename,
331
+ "text_chars": 0,
332
+ "preview_chars": 0,
333
+ "byte_size": int(_research_material_size_hint(file) or 0),
334
+ "fingerprint": "",
335
+ "error": str(exc)[:200],
336
+ }
337
+
338
  def extract_text_from_files(uploaded_files: Iterable, max_chars=None):
339
  if max_chars is None:
340
  max_chars = APP_CONFIG.get("max_extract_chars", 150000)