A5-R3-R7-R2B adaptive semantic front matter + structural heading identity

#117
core/generation_runner.py CHANGED
@@ -1755,6 +1755,40 @@ class GenerationRunner:
1755
  if not expected or expected != actual:
1756
  raise RuntimeError(f"english_section_integrity_seal_mismatch:{section_title}")
1757
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1758
  @staticmethod
1759
  def _replace_keywords(manuscript: str, keywords) -> str:
1760
  value = str(manuscript or '')
@@ -1764,10 +1798,19 @@ class GenerationRunner:
1764
  rendered = str(keywords or '').strip()
1765
  pattern = re.compile(r'(?ms)(^##\s+Keywords\s*$\n)(.*?)(?=^#\s+[^#].*?$|\Z)')
1766
  matches = list(pattern.finditer(value))
1767
- if len(matches) != 1:
1768
- raise RuntimeError(f'keywords_heading_count_invalid:{len(matches)}')
1769
- match = matches[0]
1770
- return value[:match.start()] + match.group(1) + rendered + '\n\n' + value[match.end():].lstrip('\n')
 
 
 
 
 
 
 
 
 
1771
 
1772
  @staticmethod
1773
  def _section_is_complete(tasks, completed_display_names, section_key: str) -> bool:
@@ -3157,8 +3200,37 @@ class GenerationRunner:
3157
  "Quantitative hypothesis ledger contract advanced; rebuilt locally version=%s",
3158
  expected_hypothesis_version,
3159
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3160
  state["data_metadata"] = data_metadata
3161
 
 
 
 
 
 
 
 
3162
  hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {}) if mode == "amj" else {}
3163
  hypothesis_contract = self._hypothesis_contract_directive(hypothesis_ledger)
3164
  if hypothesis_ledger:
@@ -3275,7 +3347,9 @@ class GenerationRunner:
3275
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
3276
  working_abstract, hypothesis_ledger
3277
  )
3278
- working_keywords = list(working_front.get("keywords") or [])
 
 
3279
  if not working_abstract or not working_keywords:
3280
  raise RuntimeError("working_front_matter_missing")
3281
  _, front_fact_rows = self._repair_validate_study_fact_contract(
@@ -3302,7 +3376,9 @@ class GenerationRunner:
3302
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
3303
  working_abstract, hypothesis_ledger
3304
  )
3305
- working_keywords = list(working_front.get("keywords") or [])
 
 
3306
  _, remaining_front_rows = self._repair_validate_study_fact_contract(
3307
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3308
  )
@@ -3523,6 +3599,16 @@ class GenerationRunner:
3523
  self._checkpoint("outline_ready", state, 15)
3524
  else:
3525
  full_draft_en = str(state.get("full_draft_en") or "")
 
 
 
 
 
 
 
 
 
 
3526
  # JIT text itself is already represented in agent citation state;
3527
  # refs + user literature rebuild a safe RAG baseline on resume.
3528
 
@@ -3589,7 +3675,11 @@ class GenerationRunner:
3589
  )
3590
  state['canonical_concept_ledger'] = canonical_concept_ledger
3591
  working_abstract = str(state.get("working_abstract") or "").strip()
3592
- working_keywords = list(state.get("working_keywords") or [])
 
 
 
 
3593
  if working_abstract and not state.get("english_finalized"):
3594
  if hypothesis_ledger:
3595
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
@@ -3614,7 +3704,9 @@ class GenerationRunner:
3614
  candidate_abstract = self._normalize_hypothesis_ordinal_aliases(
3615
  candidate_abstract, hypothesis_ledger
3616
  )
3617
- candidate_keywords = list(working_front.get("keywords") or [])
 
 
3618
  _, remaining = self._repair_validate_study_fact_contract(
3619
  mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
3620
  )
@@ -3838,7 +3930,9 @@ class GenerationRunner:
3838
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
3839
  working_abstract, hypothesis_ledger
3840
  )
3841
- working_keywords = list(working_front.get("keywords") or [])
 
 
3842
  if not working_abstract or not working_keywords:
3843
  raise RuntimeError("working_front_matter_missing")
3844
  _, front_fact_rows = self._repair_validate_study_fact_contract(
@@ -3860,7 +3954,9 @@ class GenerationRunner:
3860
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
3861
  working_abstract, hypothesis_ledger
3862
  )
3863
- working_keywords = list(working_front.get("keywords") or [])
 
 
3864
  _, remaining = self._repair_validate_study_fact_contract(
3865
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3866
  )
@@ -4999,7 +5095,9 @@ The previous attempt for this same manuscript unit was rejected by validation. R
4999
  final_abstract = self._normalize_hypothesis_ordinal_aliases(
5000
  final_abstract, hypothesis_ledger
5001
  )
5002
- final_keywords = list(final_front.get("keywords") or [])
 
 
5003
  if not final_abstract or not final_keywords:
5004
  raise RuntimeError("final_front_matter_missing")
5005
  final_front_fact_rows = self._study_fact_violations(
@@ -5026,7 +5124,9 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5026
  final_abstract = self._normalize_hypothesis_ordinal_aliases(
5027
  final_abstract, hypothesis_ledger
5028
  )
5029
- final_keywords = list(final_front.get("keywords") or [])
 
 
5030
  if not final_abstract or not final_keywords:
5031
  raise RuntimeError("final_front_matter_missing_after_retry")
5032
  remaining_final_fact_rows = self._study_fact_violations(
@@ -5052,7 +5152,28 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5052
  "examples":final_front_fact_rows[:3],
5053
  })
5054
  state.pop("final_abstract_retry_exhausted", None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5055
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
 
5056
  full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
5057
  if mode == "amj" and hypothesis_ledger:
5058
  post_front_hypothesis_rows = self._hypothesis_contract_violations(
@@ -5103,6 +5224,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
5103
  "translation_sections": translation_sections,
5104
  "translation_glossary": translation_glossary,
5105
  "final_abstract": final_abstract,
 
5106
  "final_keywords": final_keywords,
5107
  "front_matter_phase": "final",
5108
  "final_reference_count": final_reference_count,
 
1755
  if not expected or expected != actual:
1756
  raise RuntimeError(f"english_section_integrity_seal_mismatch:{section_title}")
1757
 
1758
+ @staticmethod
1759
+ def _apply_quantitative_front_matter_contract(mode: str, topic: str, keywords: str, data_metadata: dict):
1760
+ """Preserve original research seeds through retrieval and reasoning.
1761
+
1762
+ Reader-facing AMJ title/keywords are reconciled only after the empirical
1763
+ scope and completed manuscript are frozen.
1764
+ """
1765
+ return str(topic or "").strip(), str(keywords or "").strip()
1766
+
1767
+ @staticmethod
1768
+ def _canonical_quantitative_front_matter_keywords(mode: str, candidate_keywords, data_metadata: dict):
1769
+ """Normalize model-generated keywords without replacing semantic context."""
1770
+ values = []
1771
+ seen = set()
1772
+ for item in candidate_keywords or []:
1773
+ value = re.sub(r"\s+", " ", str(item or "")).strip()
1774
+ key = value.casefold()
1775
+ if value and key not in seen:
1776
+ values.append(value)
1777
+ seen.add(key)
1778
+ return values
1779
+
1780
+ @staticmethod
1781
+ def _replace_title(manuscript: str, title: str) -> str:
1782
+ value = str(manuscript or "")
1783
+ rendered = re.sub(r"\s+", " ", str(title or "")).strip()
1784
+ if not rendered:
1785
+ return value
1786
+ updated, count = re.subn(r"(?m)^#\s+.+$", f"# {rendered}", value, count=1)
1787
+ if count == 1:
1788
+ return updated
1789
+ logging.warning("Manuscript title heading missing during final reconciliation; reconstructing canonical H1 instead of failing generation.")
1790
+ return f"# {rendered}\n\n" + value.lstrip("\n")
1791
+
1792
  @staticmethod
1793
  def _replace_keywords(manuscript: str, keywords) -> str:
1794
  value = str(manuscript or '')
 
1798
  rendered = str(keywords or '').strip()
1799
  pattern = re.compile(r'(?ms)(^##\s+Keywords\s*$\n)(.*?)(?=^#\s+[^#].*?$|\Z)')
1800
  matches = list(pattern.finditer(value))
1801
+ if len(matches) == 1:
1802
+ match = matches[0]
1803
+ return value[:match.start()] + match.group(1) + rendered + '\n\n' + value[match.end():].lstrip('\n')
1804
+ logging.warning("Keywords heading missing or ambiguous during final reconciliation; rebuilding front-matter keyword block without aborting generation | count=%s", len(matches))
1805
+ abstract_pattern = re.compile(r'(?ms)(^##\s+Abstract\s*$\n.*?)(?=^#\s+[^#].*?$|\Z)')
1806
+ abstract_match = abstract_pattern.search(value)
1807
+ block = f"## Keywords\n{rendered}\n\n"
1808
+ if abstract_match:
1809
+ return value[:abstract_match.end()].rstrip() + "\n\n" + block + value[abstract_match.end():].lstrip("\n")
1810
+ title_match = re.search(r'(?m)^#\s+.+$', value)
1811
+ if title_match:
1812
+ return value[:title_match.end()] + "\n\n" + block + value[title_match.end():].lstrip("\n")
1813
+ return block + value.lstrip("\n")
1814
 
1815
  @staticmethod
1816
  def _section_is_complete(tasks, completed_display_names, section_key: str) -> bool:
 
3200
  "Quantitative hypothesis ledger contract advanced; rebuilt locally version=%s",
3201
  expected_hypothesis_version,
3202
  )
3203
+ front_contract = dict(data_metadata.get("front_matter_contract") or {})
3204
+ expected_front_version = str(getattr(quant_engine, "FRONT_MATTER_CONTRACT_VERSION", ""))
3205
+ if front_contract.get("version") != expected_front_version:
3206
+ constructs = dict(data_metadata.get("constructs") or {})
3207
+ rebuilt_front = quant_engine.build_front_matter_contract(
3208
+ topic, keywords, [
3209
+ constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")
3210
+ ]
3211
+ )
3212
+ if rebuilt_front:
3213
+ data_metadata["front_matter_contract"] = rebuilt_front
3214
+ else:
3215
+ logging.warning("Quantitative front-matter semantic contract could not be rebuilt; continuing with existing research seeds and degraded final reconciliation.")
3216
+ prior_translation_sections = dict(state.get("translation_sections") or {})
3217
+ prior_translation_sections.pop("__front_matter__", None)
3218
+ state["translation_sections"] = prior_translation_sections
3219
+ state["translation_complete"] = False
3220
+ state["package_validated"] = False
3221
+ logging.warning(
3222
+ "Quantitative front-matter contract advanced; rebuilt locally version=%s",
3223
+ expected_front_version,
3224
+ )
3225
  state["data_metadata"] = data_metadata
3226
 
3227
+ source_topic, source_keywords = topic, keywords
3228
+ topic, keywords = self._apply_quantitative_front_matter_contract(
3229
+ mode, topic, keywords, data_metadata
3230
+ )
3231
+ state.pop("effective_topic", None)
3232
+ state.pop("effective_keywords", None)
3233
+
3234
  hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {}) if mode == "amj" else {}
3235
  hypothesis_contract = self._hypothesis_contract_directive(hypothesis_ledger)
3236
  if hypothesis_ledger:
 
3347
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
3348
  working_abstract, hypothesis_ledger
3349
  )
3350
+ working_keywords = self._canonical_quantitative_front_matter_keywords(
3351
+ mode, list(working_front.get("keywords") or []), data_metadata
3352
+ )
3353
  if not working_abstract or not working_keywords:
3354
  raise RuntimeError("working_front_matter_missing")
3355
  _, front_fact_rows = self._repair_validate_study_fact_contract(
 
3376
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
3377
  working_abstract, hypothesis_ledger
3378
  )
3379
+ working_keywords = self._canonical_quantitative_front_matter_keywords(
3380
+ mode, list(working_front.get("keywords") or []), data_metadata
3381
+ )
3382
  _, remaining_front_rows = self._repair_validate_study_fact_contract(
3383
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3384
  )
 
3599
  self._checkpoint("outline_ready", state, 15)
3600
  else:
3601
  full_draft_en = str(state.get("full_draft_en") or "")
3602
+ if str(data_metadata.get("source_type") or "") == "system_generated" and mode == "amj":
3603
+ full_draft_en = self._replace_title(full_draft_en, topic)
3604
+ canonical_resume_keywords = self._canonical_quantitative_front_matter_keywords(
3605
+ mode, [], data_metadata
3606
+ )
3607
+ full_draft_en = self._replace_keywords(full_draft_en, canonical_resume_keywords)
3608
+ state["full_draft_en"] = full_draft_en
3609
+ state["working_keywords"] = canonical_resume_keywords
3610
+ if state.get("final_keywords") is not None:
3611
+ state["final_keywords"] = canonical_resume_keywords
3612
  # JIT text itself is already represented in agent citation state;
3613
  # refs + user literature rebuild a safe RAG baseline on resume.
3614
 
 
3675
  )
3676
  state['canonical_concept_ledger'] = canonical_concept_ledger
3677
  working_abstract = str(state.get("working_abstract") or "").strip()
3678
+ working_keywords = self._canonical_quantitative_front_matter_keywords(
3679
+ mode, list(state.get("working_keywords") or []), data_metadata
3680
+ )
3681
+ if working_keywords:
3682
+ state["working_keywords"] = working_keywords
3683
  if working_abstract and not state.get("english_finalized"):
3684
  if hypothesis_ledger:
3685
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
 
3704
  candidate_abstract = self._normalize_hypothesis_ordinal_aliases(
3705
  candidate_abstract, hypothesis_ledger
3706
  )
3707
+ candidate_keywords = self._canonical_quantitative_front_matter_keywords(
3708
+ mode, list(working_front.get("keywords") or []), data_metadata
3709
+ )
3710
  _, remaining = self._repair_validate_study_fact_contract(
3711
  mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
3712
  )
 
3930
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
3931
  working_abstract, hypothesis_ledger
3932
  )
3933
+ working_keywords = self._canonical_quantitative_front_matter_keywords(
3934
+ mode, list(working_front.get("keywords") or []), data_metadata
3935
+ )
3936
  if not working_abstract or not working_keywords:
3937
  raise RuntimeError("working_front_matter_missing")
3938
  _, front_fact_rows = self._repair_validate_study_fact_contract(
 
3954
  working_abstract = self._normalize_hypothesis_ordinal_aliases(
3955
  working_abstract, hypothesis_ledger
3956
  )
3957
+ working_keywords = self._canonical_quantitative_front_matter_keywords(
3958
+ mode, list(working_front.get("keywords") or []), data_metadata
3959
+ )
3960
  _, remaining = self._repair_validate_study_fact_contract(
3961
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3962
  )
 
5095
  final_abstract = self._normalize_hypothesis_ordinal_aliases(
5096
  final_abstract, hypothesis_ledger
5097
  )
5098
+ final_keywords = self._canonical_quantitative_front_matter_keywords(
5099
+ mode, list(final_front.get("keywords") or []), data_metadata
5100
+ )
5101
  if not final_abstract or not final_keywords:
5102
  raise RuntimeError("final_front_matter_missing")
5103
  final_front_fact_rows = self._study_fact_violations(
 
5124
  final_abstract = self._normalize_hypothesis_ordinal_aliases(
5125
  final_abstract, hypothesis_ledger
5126
  )
5127
+ final_keywords = self._canonical_quantitative_front_matter_keywords(
5128
+ mode, list(final_front.get("keywords") or []), data_metadata
5129
+ )
5130
  if not final_abstract or not final_keywords:
5131
  raise RuntimeError("final_front_matter_missing_after_retry")
5132
  remaining_final_fact_rows = self._study_fact_violations(
 
5152
  "examples":final_front_fact_rows[:3],
5153
  })
5154
  state.pop("final_abstract_retry_exhausted", None)
5155
+ final_title = str(topic or "").strip()
5156
+ if mode == "amj" and str(data_metadata.get("source_type") or "") == "system_generated":
5157
+ semantic_contract = dict(data_metadata.get("front_matter_contract") or {})
5158
+ reconciled_front = agent.reconcile_quantitative_reader_front_matter(
5159
+ topic=source_topic, keywords=source_keywords, contract=semantic_contract,
5160
+ manuscript=body_only,
5161
+ )
5162
+ candidate_title = re.sub(r"\s+", " ", str(reconciled_front.get("title") or "")).strip()
5163
+ candidate_keywords = self._canonical_quantitative_front_matter_keywords(
5164
+ mode, list(reconciled_front.get("keywords") or []), data_metadata
5165
+ )
5166
+ if candidate_title:
5167
+ final_title = candidate_title
5168
+ if 4 <= len(candidate_keywords) <= 6:
5169
+ final_keywords = candidate_keywords
5170
+ else:
5171
+ logging.warning(
5172
+ "Adaptive final keyword reconciliation returned nonpreferred count=%s; preserving validated Final Front Matter keywords.",
5173
+ len(candidate_keywords),
5174
+ )
5175
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
5176
+ full_draft_en = self._replace_title(full_draft_en, final_title)
5177
  full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
5178
  if mode == "amj" and hypothesis_ledger:
5179
  post_front_hypothesis_rows = self._hypothesis_contract_violations(
 
5224
  "translation_sections": translation_sections,
5225
  "translation_glossary": translation_glossary,
5226
  "final_abstract": final_abstract,
5227
+ "final_title": final_title,
5228
  "final_keywords": final_keywords,
5229
  "front_matter_phase": "final",
5230
  "final_reference_count": final_reference_count,
core/pipeline_agent.py CHANGED
@@ -1761,6 +1761,179 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1761
  )
1762
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
1763
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1764
  def generate_working_front_matter(
1765
  self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''
1766
  ):
 
1761
  )
1762
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
1763
 
1764
+ @staticmethod
1765
+ def _quantitative_front_matter_fallback(contract):
1766
+ """Last-resort degraded front matter derived only from frozen authority.
1767
+
1768
+ This fallback is intentionally plain. It is used only after bounded
1769
+ semantic reconciliation cannot produce a validated result; it never
1770
+ replaces the normal LLM path.
1771
+ """
1772
+ constructs = dict((contract or {}).get("constructs") or {})
1773
+ roles = list((contract or {}).get("construct_roles") or ("predictor", "mediator", "outcome"))
1774
+ values = [re.sub(r"\s+", " ", str(constructs.get(role) or "")).strip() for role in roles]
1775
+ values = [item for item in values if item]
1776
+ if not values:
1777
+ return {"title": "Quantitative Management Study", "keywords": ["quantitative research", "management research", "empirical study", "evidence"]}
1778
+ title = ", ".join(values[:-1]) + ((", and " if len(values) > 2 else " and ") + values[-1] if len(values) > 1 else "")
1779
+ keywords = list(values)
1780
+ for item in ("quantitative research", "empirical study", "management research"):
1781
+ if len(keywords) >= 4:
1782
+ break
1783
+ if item.casefold() not in {value.casefold() for value in keywords}:
1784
+ keywords.append(item)
1785
+ return {"title": title, "keywords": keywords[:6]}
1786
+
1787
+ @staticmethod
1788
+ def _validate_quantitative_front_matter_candidate(candidate, contract):
1789
+ """Validate structure and frozen-construct coverage, not prose style."""
1790
+ if not isinstance(candidate, dict):
1791
+ return ["candidate_not_object"]
1792
+ title = re.sub(r"\s+", " ", str(candidate.get("title") or "")).strip()
1793
+ raw_keywords = candidate.get("keywords")
1794
+ if not title:
1795
+ return ["title_missing"]
1796
+ if not isinstance(raw_keywords, list):
1797
+ return ["keywords_not_array"]
1798
+ keywords = [re.sub(r"\s+", " ", str(item or "")).strip() for item in raw_keywords]
1799
+ keywords = [item for item in keywords if item]
1800
+ issues = []
1801
+ if not 4 <= len(keywords) <= 6:
1802
+ issues.append("keywords_count_must_be_4_to_6")
1803
+ if len({item.casefold() for item in keywords}) != len(keywords):
1804
+ issues.append("keywords_must_be_distinct")
1805
+ constructs = dict((contract or {}).get("constructs") or {})
1806
+ roles = list((contract or {}).get("construct_roles") or constructs.keys())
1807
+ combined = (title + " ; " + " ; ".join(keywords)).casefold()
1808
+ missing = [
1809
+ str(constructs.get(role) or "").strip()
1810
+ for role in roles
1811
+ if str(constructs.get(role) or "").strip()
1812
+ and str(constructs.get(role) or "").strip().casefold() not in combined
1813
+ ]
1814
+ if missing:
1815
+ issues.append("missing_frozen_constructs:" + " | ".join(missing))
1816
+ return issues
1817
+
1818
+ def reconcile_quantitative_reader_front_matter(self, *, topic, keywords, contract, manuscript=""):
1819
+ """Adaptively reconcile final AMJ title/keywords to frozen evidence.
1820
+
1821
+ Original user topic/keyword seeds are retained as research context. The
1822
+ model may preserve domain/context terms, but it must not present an
1823
+ unmeasured concept as a focal studied construct. A reasoning critic
1824
+ validates semantic fidelity. After bounded retries, deterministic
1825
+ degraded-mode fallback keeps packaging recoverable.
1826
+ """
1827
+ contract = dict(contract or {})
1828
+ constructs = dict(contract.get("constructs") or {})
1829
+ roles = list(contract.get("construct_roles") or constructs.keys())
1830
+ frozen = [{"role": role, "name": constructs.get(role)} for role in roles if constructs.get(role)]
1831
+ if not frozen:
1832
+ logging.warning("Front-matter semantic contract missing frozen constructs; using degraded fallback.")
1833
+ return self._quantitative_front_matter_fallback(contract)
1834
+
1835
+ source_topic = str(contract.get("source_topic") or topic or "").strip()
1836
+ source_keywords = list(contract.get("source_keywords") or [])
1837
+ if not source_keywords:
1838
+ source_keywords = [item.strip() for item in re.split(r"[,;\n|]+", str(keywords or "")) if item.strip()]
1839
+ body_context = self._abstract_manuscript_context(str(manuscript or ""), 8000) if manuscript else ""
1840
+ feedback = ""
1841
+ last_candidate = None
1842
+
1843
+ for attempt in range(2):
1844
+ prompt = f"""Reconcile the reader-facing English TITLE and KEYWORDS for a completed management-journal quantitative manuscript.
1845
+ Return JSON only: {{"title":"...","keywords":["...", "..."]}}.
1846
+
1847
+ SEMANTIC RULES:
1848
+ 1. The frozen construct-role list below is authoritative for what the study actually measures/models.
1849
+ 2. Preserve useful domain, industry, population, technology, or methodological context from the user's topic/keyword seeds when academically appropriate.
1850
+ 3. Do NOT present any concept outside the frozen construct-role list as a focal measured, modeled, mediating, moderating, or outcome variable.
1851
+ 4. Contextual terms may appear when they describe setting/domain rather than an unmeasured focal construct.
1852
+ 5. Produce a natural journal-style title, not a mechanical template.
1853
+ 6. Return 4-6 distinct academic keywords. Include the frozen constructs and add only genuinely useful context/method terms.
1854
+ 7. Do not mention this reconciliation process.
1855
+
1856
+ <ORIGINAL_USER_TOPIC>
1857
+ {source_topic}
1858
+ </ORIGINAL_USER_TOPIC>
1859
+ <ORIGINAL_USER_KEYWORD_SEEDS>
1860
+ {json.dumps(source_keywords, ensure_ascii=False)}
1861
+ </ORIGINAL_USER_KEYWORD_SEEDS>
1862
+ <FROZEN_CONSTRUCT_ROLES>
1863
+ {json.dumps(frozen, ensure_ascii=False)}
1864
+ </FROZEN_CONSTRUCT_ROLES>
1865
+ <COMPLETED_MANUSCRIPT_CONTEXT>
1866
+ {body_context}
1867
+ </COMPLETED_MANUSCRIPT_CONTEXT>
1868
+ <VALIDATION_FEEDBACK>
1869
+ {feedback}
1870
+ </VALIDATION_FEEDBACK>"""
1871
+ try:
1872
+ response = self.call_deepseek(
1873
+ "You are a senior management-journal editor. Reconcile title and keywords to the study's frozen empirical scope without erasing legitimate domain context.",
1874
+ prompt,
1875
+ max_tokens=900,
1876
+ temp=0.0,
1877
+ model_role="writing",
1878
+ thinking=False,
1879
+ allow_fallback=False,
1880
+ response_format={"type": "json_object"},
1881
+ logical_node="front_matter_reconciliation",
1882
+ )
1883
+ candidate = self._extract_json_from_text(response)
1884
+ last_candidate = candidate if isinstance(candidate, dict) else None
1885
+ except Exception as exc:
1886
+ logging.warning("Adaptive front-matter generation unavailable; degraded fallback will preserve delivery | error=%s", self._safe_source_error(exc))
1887
+ break
1888
+
1889
+ local_issues = self._validate_quantitative_front_matter_candidate(candidate, contract)
1890
+ if local_issues:
1891
+ feedback = "Local validation issues: " + "; ".join(local_issues)
1892
+ logging.warning("Adaptive front-matter local retry | attempt=%s/2 | %s", attempt + 1, feedback)
1893
+ continue
1894
+
1895
+ critic_prompt = f"""Audit the proposed title/keywords against the frozen empirical construct roles.
1896
+ Return JSON only: {{"valid":true,"unmeasured_focal_constructs":[],"missing_frozen_constructs":[],"reason":"..."}}.
1897
+ A contextual/domain term is allowed. Mark INVALID only when the proposal makes an unmeasured concept appear to be a focal measured/modeled variable, omits a frozen focal construct from the reader-facing scope, or materially misstates the empirical design.
1898
+ <FROZEN_CONSTRUCT_ROLES>{json.dumps(frozen, ensure_ascii=False)}</FROZEN_CONSTRUCT_ROLES>
1899
+ <ORIGINAL_USER_TOPIC>{source_topic}</ORIGINAL_USER_TOPIC>
1900
+ <CANDIDATE>{json.dumps(candidate, ensure_ascii=False)}</CANDIDATE>"""
1901
+ try:
1902
+ critic_raw = self.call_deepseek(
1903
+ "You are a rigorous empirical-methods editor checking title-to-evidence fidelity.",
1904
+ critic_prompt,
1905
+ max_tokens=500,
1906
+ temp=0.0,
1907
+ model_role="reasoning",
1908
+ thinking=True,
1909
+ allow_fallback=False,
1910
+ response_format={"type": "json_object"},
1911
+ logical_node="front_matter_fidelity_critic",
1912
+ )
1913
+ critic = self._extract_json_from_text(critic_raw)
1914
+ except Exception as exc:
1915
+ logging.warning("Front-matter semantic critic unavailable; accepting structurally valid candidate with frozen-construct coverage | error=%s", self._safe_source_error(exc))
1916
+ critic = {"valid": True, "unmeasured_focal_constructs": [], "missing_frozen_constructs": []}
1917
+
1918
+ invalid = not bool((critic or {}).get("valid"))
1919
+ extra = [str(item).strip() for item in ((critic or {}).get("unmeasured_focal_constructs") or []) if str(item).strip()]
1920
+ missing = [str(item).strip() for item in ((critic or {}).get("missing_frozen_constructs") or []) if str(item).strip()]
1921
+ if not invalid and not extra and not missing:
1922
+ return {
1923
+ "title": re.sub(r"\s+", " ", str(candidate.get("title") or "")).strip(),
1924
+ "keywords": [re.sub(r"\s+", " ", str(item)).strip() for item in candidate.get("keywords") or [] if str(item).strip()],
1925
+ }
1926
+ feedback = "Semantic critic rejected candidate. " + str((critic or {}).get("reason") or "")
1927
+ if extra:
1928
+ feedback += " Unmeasured focal constructs: " + ", ".join(extra) + "."
1929
+ if missing:
1930
+ feedback += " Missing frozen constructs: " + ", ".join(missing) + "."
1931
+ logging.warning("Adaptive front-matter semantic retry | attempt=%s/2 | %s", attempt + 1, feedback[:900])
1932
+
1933
+ fallback = self._quantitative_front_matter_fallback(contract)
1934
+ logging.warning("Adaptive front-matter reconciliation exhausted; using deterministic degraded fallback instead of failing manuscript generation.")
1935
+ return fallback
1936
+
1937
  def generate_working_front_matter(
1938
  self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''
1939
  ):
core/quantitative_engine.py CHANGED
@@ -21,6 +21,7 @@ class QuantitativeEngine:
21
  QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-v2"
22
  QUANTITATIVE_TABLE_MANIFEST_VERSION = "quantitative-table-manifest-v1"
23
  HYPOTHESIS_LEDGER_VERSION = "quantitative-hypothesis-ledger-v1"
 
24
  TARGET_TABLE_BINDING_VERSION = "target-quantitative-table-binding-v1"
25
  SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
26
 
@@ -108,6 +109,43 @@ class QuantitativeEngine:
108
  },
109
  }
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  @classmethod
112
  def build_hypothesis_ledger(cls, constructs):
113
  """Freeze one result-independent AMJ hypothesis identity contract.
@@ -2050,6 +2088,9 @@ Keywords: {keywords}"""
2050
  ],
2051
  }
2052
  metadata["semantic_claim_frame_version"] = self.SEMANTIC_CLAIM_FRAME_VERSION
 
 
 
2053
  metadata["hypothesis_ledger"] = self.build_hypothesis_ledger(construct_names)
2054
  metadata["quantitative_table_manifest"] = self.build_table_manifest(
2055
  validity_df, fl_matrix_df, path_table, spearman
 
21
  QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-v2"
22
  QUANTITATIVE_TABLE_MANIFEST_VERSION = "quantitative-table-manifest-v1"
23
  HYPOTHESIS_LEDGER_VERSION = "quantitative-hypothesis-ledger-v1"
24
+ FRONT_MATTER_CONTRACT_VERSION = "quantitative-front-matter-contract-v2"
25
  TARGET_TABLE_BINDING_VERSION = "target-quantitative-table-binding-v1"
26
  SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
27
 
 
109
  },
110
  }
111
 
112
+ @classmethod
113
+ def build_front_matter_contract(cls, topic, keywords, constructs):
114
+ """Freeze semantic authority without hard-coding reader-facing prose.
115
+
116
+ The evidence engine owns construct identities/roles. The user's topic
117
+ and keyword seeds remain available for retrieval, literature synthesis,
118
+ and domain reasoning. Reader-facing title/keywords are reconciled later
119
+ by the model against this frozen contract and then deterministically
120
+ validated.
121
+ """
122
+ role_names = ("predictor", "mediator", "outcome")
123
+ if isinstance(constructs, dict):
124
+ role_map = {
125
+ role: re.sub(r"\s+", " ", str(constructs.get(role) or "")).strip()
126
+ for role in role_names
127
+ }
128
+ else:
129
+ values = [re.sub(r"\s+", " ", str(item or "")).strip() for item in (constructs or [])]
130
+ if len(values) != len(role_names):
131
+ return {}
132
+ role_map = dict(zip(role_names, values))
133
+ if any(not role_map.get(role) for role in role_names):
134
+ return {}
135
+ source_keywords = [
136
+ re.sub(r"\s+", " ", item).strip()
137
+ for item in re.split(r"[,;\n|]+", str(keywords or ""))
138
+ if re.sub(r"\s+", " ", item).strip()
139
+ ]
140
+ return {
141
+ "version": cls.FRONT_MATTER_CONTRACT_VERSION,
142
+ "policy": "system_generated_frozen_construct_semantic_authority",
143
+ "source_topic": re.sub(r"\s+", " ", str(topic or "")).strip(),
144
+ "source_keywords": source_keywords,
145
+ "construct_roles": list(role_names),
146
+ "constructs": role_map,
147
+ }
148
+
149
  @classmethod
150
  def build_hypothesis_ledger(cls, constructs):
151
  """Freeze one result-independent AMJ hypothesis identity contract.
 
2088
  ],
2089
  }
2090
  metadata["semantic_claim_frame_version"] = self.SEMANTIC_CLAIM_FRAME_VERSION
2091
+ metadata["front_matter_contract"] = self.build_front_matter_contract(
2092
+ topic, keywords, metadata.get("constructs") or construct_names
2093
+ )
2094
  metadata["hypothesis_ledger"] = self.build_hypothesis_ledger(construct_names)
2095
  metadata["quantitative_table_manifest"] = self.build_table_manifest(
2096
  validity_df, fl_matrix_df, path_table, spearman
smartpaper_restore_bridge/frontend/index.html CHANGED
@@ -542,25 +542,20 @@
542
  target.textContent = value;
543
  }
544
 
545
- const UNNUMBERED_HEADINGS = new Set([
546
- "abstract","keyword","keywords","references","reference list",
547
- "摘要","关键词","关键字","参考文献",
548
- "аннотация","ключевое слово","ключевые слова","литература","список литературы",
549
- "аңдатпа","түйіндеме","түйін сөздер","түйінді сөздер","кілт сөздер",
550
- "әдебиеттер","пайдаланылған әдебиеттер"
551
- ]);
552
-
553
- const UNNUMBERED_HEADING_PREFIXES = ["appendix", "附录", "приложение", "қосымша"];
554
-
555
- function isUnnumberedHeading(value) {
556
- const key = String(value || "").trim().toLocaleLowerCase();
557
- return UNNUMBERED_HEADINGS.has(key) || UNNUMBERED_HEADING_PREFIXES.some(prefix => key.startsWith(prefix));
558
- }
559
-
560
  function cleanHeadingTitle(value) {
561
  return String(value || "").trim().replace(/^\d+(?:\.\d+){0,2}\s+/, "");
562
  }
563
 
 
 
 
 
 
 
 
 
 
 
564
  function numberMarkdownHeadings(value) {
565
  const text = String(value || "").replace(/\r/g, "");
566
  const lines = text.split("\n");
@@ -569,13 +564,25 @@
569
  const match = line.match(/^(#{1,3})\s+(.+?)\s*$/);
570
  return match ? { index, level:match[1].length, title:cleanHeadingTitle(match[2]) } : null;
571
  }).filter(Boolean);
572
- const hasFrontMatter = headingRows.some(row => isUnnumberedHeading(row.title));
573
- const firstH1 = headingRows.find(row => row.level === 1);
 
 
 
 
 
 
 
 
 
 
 
 
574
  for (const row of headingRows) {
575
- const key = row.title.toLocaleLowerCase();
576
- const isKnownUnnumbered = isUnnumberedHeading(row.title);
577
- const isTitle = hasFrontMatter && firstH1 && row.index === firstH1.index && !isKnownUnnumbered;
578
- if (isKnownUnnumbered || isTitle) {
579
  lines[row.index] = row.title;
580
  continue;
581
  }
 
542
  target.textContent = value;
543
  }
544
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  function cleanHeadingTitle(value) {
546
  return String(value || "").trim().replace(/^\d+(?:\.\d+){0,2}\s+/, "");
547
  }
548
 
549
+ function segmentHasSixColumnMarkdownTable(lines, startIndex, endIndex) {
550
+ for (let index = startIndex; index < endIndex; index += 1) {
551
+ const line = String(lines[index] || "").trim();
552
+ if (!line.includes("|")) continue;
553
+ const cells = line.replace(/^\|/, "").replace(/\|$/, "").split("|").map(cell => cell.trim());
554
+ if (cells.length === 6 && cells.every(Boolean)) return true;
555
+ }
556
+ return false;
557
+ }
558
+
559
  function numberMarkdownHeadings(value) {
560
  const text = String(value || "").replace(/\r/g, "");
561
  const lines = text.split("\n");
 
564
  const match = line.match(/^(#{1,3})\s+(.+?)\s*$/);
565
  return match ? { index, level:match[1].length, title:cleanHeadingTitle(match[2]) } : null;
566
  }).filter(Boolean);
567
+ const h1Rows = headingRows.filter(row => row.level === 1);
568
+ const firstH1 = h1Rows[0] || null;
569
+ const secondH1 = h1Rows[1] || null;
570
+ const firstH1IsTitle = Boolean(firstH1);
571
+ const structuralUnnumberedH1 = new Set();
572
+ if (h1Rows.length >= 2) structuralUnnumberedH1.add(h1Rows[h1Rows.length - 1].index);
573
+ if (h1Rows.length >= 3) {
574
+ const penultimate = h1Rows[h1Rows.length - 2];
575
+ const last = h1Rows[h1Rows.length - 1];
576
+ if (segmentHasSixColumnMarkdownTable(lines, penultimate.index + 1, last.index)) {
577
+ structuralUnnumberedH1.add(penultimate.index);
578
+ }
579
+ }
580
+
581
  for (const row of headingRows) {
582
+ const isTitle = firstH1IsTitle && firstH1 && row.index === firstH1.index;
583
+ const isFrontMatterChild = firstH1IsTitle && firstH1 && row.level > 1 && row.index > firstH1.index && (!secondH1 || row.index < secondH1.index);
584
+ const isStructuralBackMatter = row.level === 1 && structuralUnnumberedH1.has(row.index);
585
+ if (isTitle || isFrontMatterChild || isStructuralBackMatter) {
586
  lines[row.index] = row.title;
587
  continue;
588
  }
utils/docx_builder.py CHANGED
@@ -15,19 +15,34 @@ class DocxBuildError(RuntimeError):
15
  """Raised when markdown cannot be rendered into a valid DOCX document."""
16
 
17
 
18
- _UNNUMBERED_REFERENCE_HEADINGS = frozenset({
19
- "references",
20
- "参考文献",
21
- "литература",
22
- "список литературы",
23
- "пайдаланылған әдебиеттер",
24
- })
25
 
26
-
27
- def _normalize_heading_label(text):
28
- """Normalize a heading for exact front/back-matter label matching."""
29
- value = " ".join(str(text or "").replace("\u00a0", " ").split()).casefold()
30
- return value.rstrip(" ::")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
 
33
  def _table_column_count(token):
@@ -72,11 +87,13 @@ def _qualitative_appendix_table_ordinal(markdown_content):
72
 
73
 
74
  class DocxRenderer(mistune.HTMLRenderer):
75
- def __init__(self, *, qualitative_appendix_table_ordinal=None, semantic_qualitative_appendix=False):
76
  super().__init__()
77
  self.doc = Document()
78
  self.h_counters = [0] * 6
79
- self.is_first_h1 = True
 
 
80
  self.table_ordinal = -1
81
  self.qualitative_appendix_table_ordinal = qualitative_appendix_table_ordinal
82
  self.semantic_qualitative_appendix = bool(semantic_qualitative_appendix)
@@ -108,19 +125,17 @@ class DocxRenderer(mistune.HTMLRenderer):
108
 
109
  def heading(self, text, level):
110
  clean_text = BeautifulSoup(text, 'html.parser').get_text().strip()
111
- lower_text = clean_text.lower()
112
- normalized_text = _normalize_heading_label(clean_text)
113
- skip_keywords = APP_CONFIG["blacklists"]["meta_sections"]
114
- exact_reference_heading = normalized_text in _UNNUMBERED_REFERENCE_HEADINGS
115
-
116
- if exact_reference_heading or any(kw in lower_text for kw in skip_keywords) or (level == 1 and self.is_first_h1):
117
- if level == 1:
118
- self.is_first_h1 = False
119
- self.doc.add_heading(clean_text, level=1)
 
120
  return ''
121
-
122
- if level == 1:
123
- self.is_first_h1 = False
124
 
125
  idx = level - 1
126
  if 0 <= idx < 6:
@@ -275,9 +290,14 @@ def text_to_docx(markdown_content, *, qualitative_appendix=False):
275
  raise DocxBuildError(
276
  "Qualitative Appendix semantic table identity is missing or ambiguous."
277
  )
 
 
 
278
  renderer = DocxRenderer(
279
  qualitative_appendix_table_ordinal=qualitative_table_ordinal,
280
  semantic_qualitative_appendix=semantic_qualitative_appendix,
 
 
281
  )
282
  mistune.create_markdown(renderer=renderer, plugins=['table'])(str(markdown_content or ''))
283
  doc = renderer.doc
 
15
  """Raised when markdown cannot be rendered into a valid DOCX document."""
16
 
17
 
18
+ def _manuscript_heading_structure(markdown_content, *, qualitative_appendix=False):
19
+ """Derive unnumbered heading roles from Markdown structure, not language.
 
 
 
 
 
20
 
21
+ Production manuscripts have an optional title/front-matter H1 followed by
22
+ numbered body H1s and a final References H1. ASQ additionally has a
23
+ penultimate Appendix H1. The roles are identified by position and the
24
+ existing semantic ASQ packaging flag, so translated wording is irrelevant.
25
+ """
26
+ tokens = mistune.create_markdown(renderer="ast", plugins=["table"])(str(markdown_content or ""))
27
+ headings = []
28
+ for token_index, token in enumerate(tokens):
29
+ if not isinstance(token, dict) or token.get("type") != "heading":
30
+ continue
31
+ level = int((token.get("attrs") or {}).get("level") or 0)
32
+ headings.append((token_index, level))
33
+ h1 = [(token_index, level) for token_index, level in headings if level == 1]
34
+ if not h1:
35
+ return {"first_h1_is_title": False, "unnumbered_h1_ordinals": set()}
36
+ first_h1_is_title = True
37
+ unnumbered = set()
38
+ if len(h1) >= 2:
39
+ unnumbered.add(len(h1)) # final References H1
40
+ if qualitative_appendix and len(h1) >= 3:
41
+ unnumbered.add(len(h1) - 1) # ASQ Appendix H1
42
+ return {
43
+ "first_h1_is_title": first_h1_is_title,
44
+ "unnumbered_h1_ordinals": unnumbered,
45
+ }
46
 
47
 
48
  def _table_column_count(token):
 
87
 
88
 
89
  class DocxRenderer(mistune.HTMLRenderer):
90
+ def __init__(self, *, qualitative_appendix_table_ordinal=None, semantic_qualitative_appendix=False, first_h1_is_title=False, unnumbered_h1_ordinals=None):
91
  super().__init__()
92
  self.doc = Document()
93
  self.h_counters = [0] * 6
94
+ self.h1_ordinal = 0
95
+ self.first_h1_is_title = bool(first_h1_is_title)
96
+ self.unnumbered_h1_ordinals = set(unnumbered_h1_ordinals or set())
97
  self.table_ordinal = -1
98
  self.qualitative_appendix_table_ordinal = qualitative_appendix_table_ordinal
99
  self.semantic_qualitative_appendix = bool(semantic_qualitative_appendix)
 
125
 
126
  def heading(self, text, level):
127
  clean_text = BeautifulSoup(text, 'html.parser').get_text().strip()
128
+ level = int(level or 0)
129
+ if level == 1:
130
+ self.h1_ordinal += 1
131
+ if (self.first_h1_is_title and self.h1_ordinal == 1) or self.h1_ordinal in self.unnumbered_h1_ordinals:
132
+ self.doc.add_heading(clean_text, level=1)
133
+ return ''
134
+ elif self.first_h1_is_title and self.h1_ordinal == 1:
135
+ # Abstract/Keywords and any other front-matter child headings are
136
+ # structurally unnumbered regardless of language.
137
+ self.doc.add_heading(clean_text, level=level)
138
  return ''
 
 
 
139
 
140
  idx = level - 1
141
  if 0 <= idx < 6:
 
290
  raise DocxBuildError(
291
  "Qualitative Appendix semantic table identity is missing or ambiguous."
292
  )
293
+ heading_structure = _manuscript_heading_structure(
294
+ markdown_content, qualitative_appendix=semantic_qualitative_appendix
295
+ )
296
  renderer = DocxRenderer(
297
  qualitative_appendix_table_ordinal=qualitative_table_ordinal,
298
  semantic_qualitative_appendix=semantic_qualitative_appendix,
299
+ first_h1_is_title=heading_structure["first_h1_is_title"],
300
+ unnumbered_h1_ordinals=heading_structure["unnumbered_h1_ordinals"],
301
  )
302
  mistune.create_markdown(renderer=renderer, plugins=['table'])(str(markdown_content or ''))
303
  doc = renderer.doc