Spaces:
Running
Running
A5-R3-R7-R2B adaptive semantic front matter + structural heading identity
#117
by GuXiaopeng - opened
- core/generation_runner.py +134 -12
- core/pipeline_agent.py +173 -0
- core/quantitative_engine.py +41 -0
- smartpaper_restore_bridge/frontend/index.html +28 -21
- utils/docx_builder.py +46 -26
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)
|
| 1768 |
-
|
| 1769 |
-
|
| 1770 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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
|
| 573 |
-
const firstH1 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
for (const row of headingRows) {
|
| 575 |
-
const
|
| 576 |
-
const
|
| 577 |
-
const
|
| 578 |
-
if (
|
| 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 |
-
|
| 19 |
-
"
|
| 20 |
-
"参考文献",
|
| 21 |
-
"литература",
|
| 22 |
-
"список литературы",
|
| 23 |
-
"пайдаланылған әдебиеттер",
|
| 24 |
-
})
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
| 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 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
| 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
|