Spaces:
Running
Running
P0-R1.1-R2-R1 Evidence-Sealed Finalization
#71
by GuXiaopeng - opened
- core/generation_runner.py +257 -70
- core/pipeline_agent.py +188 -65
- core/quantitative_engine.py +179 -122
core/generation_runner.py
CHANGED
|
@@ -45,6 +45,7 @@ CITATION_ROLE_RULES = {
|
|
| 45 |
}
|
| 46 |
TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
|
| 47 |
FINAL_CITATION_LEDGER_VERSION = 'resolved-final-ledger-v1'
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
|
|
@@ -929,6 +930,43 @@ class GenerationRunner:
|
|
| 929 |
)
|
| 930 |
return value, list(diagnostics or [])
|
| 931 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
@staticmethod
|
| 933 |
def _replace_keywords(manuscript: str, keywords) -> str:
|
| 934 |
value = str(manuscript or '')
|
|
@@ -1444,8 +1482,8 @@ class GenerationRunner:
|
|
| 1444 |
except PipelineCancelledError:
|
| 1445 |
raise
|
| 1446 |
except Exception as error:
|
| 1447 |
-
|
| 1448 |
-
|
| 1449 |
code_method = getattr(agent, 'translation_error_code', None)
|
| 1450 |
error_code = str(code_method(error) if callable(code_method) else 'TRANSLATION_PENDING')
|
| 1451 |
updated_sections[unit_key] = {
|
|
@@ -1466,8 +1504,8 @@ class GenerationRunner:
|
|
| 1466 |
'deferred_at': datetime.now(timezone.utc).isoformat(),
|
| 1467 |
}
|
| 1468 |
logging.warning(
|
| 1469 |
-
'Translation
|
| 1470 |
-
unit_name, error_code, type(error).__name__,
|
| 1471 |
)
|
| 1472 |
self._translation_progress_sections = dict(updated_sections)
|
| 1473 |
self._translation_progress_inflight.discard(str(unit_key))
|
|
@@ -1922,6 +1960,13 @@ class GenerationRunner:
|
|
| 1922 |
state["english_finalized"] = False
|
| 1923 |
state["translation_complete"] = False
|
| 1924 |
state["package_validated"] = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1925 |
|
| 1926 |
user_lit_context = str(state.get("user_lit_context") or "")
|
| 1927 |
user_data_md = str(state.get("user_data_md") or "")
|
|
@@ -1961,6 +2006,20 @@ class GenerationRunner:
|
|
| 1961 |
)
|
| 1962 |
self._checkpoint("evidence_ready", state, 6)
|
| 1963 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1964 |
self._qualitative_quote_sources = (
|
| 1965 |
self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
|
| 1966 |
)
|
|
@@ -2227,6 +2286,8 @@ class GenerationRunner:
|
|
| 2227 |
"citation_unit_section_map": citation_unit_section_map,
|
| 2228 |
"citation_validated_tags_by_unit": {},
|
| 2229 |
"citation_required_claim_counts_by_unit": {},
|
|
|
|
|
|
|
| 2230 |
"research_logic_contract": research_logic_contract,
|
| 2231 |
"working_abstract": working_abstract,
|
| 2232 |
"working_keywords": working_keywords,
|
|
@@ -2271,6 +2332,11 @@ class GenerationRunner:
|
|
| 2271 |
str(name): list(tags or [])
|
| 2272 |
for name, tags in dict(state.get("citation_validated_tags_by_unit") or {}).items()
|
| 2273 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2274 |
if not citation_minimums:
|
| 2275 |
citation_minimums, citation_reserve_targets = self._adaptive_citation_requirements(
|
| 2276 |
adaptive_structure, mode
|
|
@@ -2317,6 +2383,77 @@ class GenerationRunner:
|
|
| 2317 |
if self._preview_target_text == '':
|
| 2318 |
self._preview_target_text = str(state.get('full_draft_translated') or '')
|
| 2319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2320 |
# Target-language work remains Executor-owned but no longer blocks the
|
| 2321 |
# English writer. A dedicated single translation agent/worker keeps
|
| 2322 |
# glossary order deterministic and avoids sharing mutable writer state.
|
|
@@ -2769,19 +2906,6 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2769 |
en_resolved = recovered
|
| 2770 |
used_after = set(agent.used_citations)
|
| 2771 |
survived = used_after - used_before
|
| 2772 |
-
if not is_quarantined and section_id in self._citation_eligible_section_ids(mode):
|
| 2773 |
-
citation_required_claim_counts_by_unit[display_name] = agent.count_external_literature_claims(
|
| 2774 |
-
en_resolved, section_name=display_name
|
| 2775 |
-
)
|
| 2776 |
-
if assigned_tag_list:
|
| 2777 |
-
citation_validated_tags_by_unit[display_name] = sorted(
|
| 2778 |
-
survived, key=agent._citation_tag_number
|
| 2779 |
-
)
|
| 2780 |
-
logging.info(
|
| 2781 |
-
"Top-level citation accumulation: unit=%s section=%s validated=%s external_claims=%s",
|
| 2782 |
-
display_name, citation_unit_section_map.get(display_name, section_id),
|
| 2783 |
-
len(survived), citation_required_claim_counts_by_unit[display_name],
|
| 2784 |
-
)
|
| 2785 |
if mode == "asq" and section_id == "findings":
|
| 2786 |
en_resolved = self._render_planned_qualitative_quotes(
|
| 2787 |
en_resolved,
|
|
@@ -2789,6 +2913,43 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2789 |
data_metadata=data_metadata,
|
| 2790 |
quote_invocation_plan=quote_invocation_plan,
|
| 2791 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2792 |
if mode == "asq":
|
| 2793 |
en_resolved, study_fact_rows = agent.repair_qualitative_study_fact_contract(
|
| 2794 |
en_resolved, data_metadata, section_name=display_name
|
|
@@ -2825,37 +2986,28 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2825 |
quant_engine.validate_quantitative_study_fact_contract(
|
| 2826 |
en_resolved, data_metadata, section_name=display_name
|
| 2827 |
)
|
| 2828 |
-
|
| 2829 |
-
|
| 2830 |
-
|
| 2831 |
-
if
|
| 2832 |
-
|
| 2833 |
-
|
| 2834 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2835 |
)
|
| 2836 |
-
|
| 2837 |
-
|
| 2838 |
-
|
| 2839 |
-
|
| 2840 |
-
|
| 2841 |
-
|
| 2842 |
-
|
| 2843 |
-
|
| 2844 |
-
quality_diagnostics.append({"scope":"manuscript_redundancy","name":display_name,"status":"bounded_duplicate_removed","count":len(duplicate_rows),"examples":duplicate_rows[:3]})
|
| 2845 |
-
en_resolved, sentence_echo_rows = agent.remove_severe_cross_unit_sentence_echoes(
|
| 2846 |
-
en_resolved, full_draft_en
|
| 2847 |
-
)
|
| 2848 |
-
if sentence_echo_rows:
|
| 2849 |
-
logging.warning(
|
| 2850 |
-
"Severe cross-unit sentence echo removed | unit=%s count=%s max_similarity=%s",
|
| 2851 |
-
display_name, len(sentence_echo_rows),
|
| 2852 |
-
max(row["similarity"] for row in sentence_echo_rows),
|
| 2853 |
)
|
| 2854 |
-
quality_diagnostics.append({
|
| 2855 |
-
"scope":"manuscript_sentence_echo", "name":display_name,
|
| 2856 |
-
"status":"bounded_duplicate_removed", "count":len(sentence_echo_rows),
|
| 2857 |
-
"examples":sentence_echo_rows[:3],
|
| 2858 |
-
})
|
| 2859 |
full_draft_en += en_resolved + "\n\n"
|
| 2860 |
if assigned_tag_list:
|
| 2861 |
logging.info(
|
|
@@ -2863,8 +3015,9 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2863 |
display_name, len(assigned_tag_list), len(survived),
|
| 2864 |
)
|
| 2865 |
self._publish_preview(full_draft_en, display_name, force=True)
|
| 2866 |
-
|
| 2867 |
-
|
|
|
|
| 2868 |
actual_words = self._english_count_for_target(en_resolved)
|
| 2869 |
if not bool(sec.get("exclude_from_word_budget")):
|
| 2870 |
accumulated_deviation += actual_words - adjusted_target
|
|
@@ -2875,6 +3028,11 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2875 |
section_key = str(sec.get('section_id') or sec_title)
|
| 2876 |
if self._section_is_complete(adaptive_structure, completed_sections, section_key):
|
| 2877 |
frozen_section = self._extract_top_level_section(full_draft_en, sec_title)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2878 |
self._enqueue_translation_unit(
|
| 2879 |
translation_agent,
|
| 2880 |
unit_key=section_key, unit_name=sec_title, source_text=frozen_section,
|
|
@@ -2905,6 +3063,8 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2905 |
"citation_unit_section_map": citation_unit_section_map,
|
| 2906 |
"citation_required_claim_counts_by_unit": citation_required_claim_counts_by_unit,
|
| 2907 |
"citation_validated_tags_by_unit": citation_validated_tags_by_unit,
|
|
|
|
|
|
|
| 2908 |
"agent_state": agent.export_state(),
|
| 2909 |
"data_metadata": data_metadata,
|
| 2910 |
}
|
|
@@ -2920,30 +3080,35 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2920 |
section_order_for_fact = self._unique_top_level_sections(adaptive_structure)
|
| 2921 |
if not section_order_for_fact:
|
| 2922 |
raise RuntimeError("final_study_fact_section_plan_missing")
|
| 2923 |
-
|
| 2924 |
-
|
| 2925 |
-
|
| 2926 |
-
|
| 2927 |
-
|
| 2928 |
-
|
| 2929 |
-
|
| 2930 |
-
|
| 2931 |
-
|
| 2932 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2933 |
)
|
| 2934 |
-
|
| 2935 |
-
|
| 2936 |
-
|
| 2937 |
)
|
| 2938 |
-
if
|
| 2939 |
-
raise RuntimeError(
|
| 2940 |
-
"final_quantitative_study_fact_contract_requires_section_regeneration"
|
| 2941 |
-
)
|
| 2942 |
-
enum_checked, enum_rows = agent.repair_and_validate_enumeration_contract(
|
| 2943 |
-
full_draft_en, section_name="Final English manuscript"
|
| 2944 |
-
)
|
| 2945 |
-
if enum_checked.strip() != full_draft_en.strip() or enum_rows:
|
| 2946 |
-
raise RuntimeError("final_enumeration_contract_requires_section_regeneration")
|
| 2947 |
# Validate the completed body while the Working Abstract is still in
|
| 2948 |
# place. Final front matter must not be generated until all body
|
| 2949 |
# sections, citation cleanup, and any Discussion rebalance are frozen.
|
|
@@ -3127,6 +3292,8 @@ Output only final academic prose. Do not report the word count."""
|
|
| 3127 |
"citation_unit_section_map": citation_unit_section_map,
|
| 3128 |
"citation_required_claim_counts_by_unit": citation_required_claim_counts_by_unit,
|
| 3129 |
"citation_validated_tags_by_unit": citation_validated_tags_by_unit,
|
|
|
|
|
|
|
| 3130 |
"quality_diagnostics": quality_diagnostics,
|
| 3131 |
"english_finalized": True,
|
| 3132 |
"translation_complete": False,
|
|
@@ -3178,6 +3345,26 @@ Output only final academic prose. Do not report the word count."""
|
|
| 3178 |
'agent_state': agent.export_state(),
|
| 3179 |
})
|
| 3180 |
pct = min(96, 94 + int(index * 2 / max(1, len(final_units))))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3181 |
self._checkpoint(
|
| 3182 |
f'translation_{unit_key}', state, pct,
|
| 3183 |
display_node=f'translation_final_gate:{index}:{len(final_units)}',
|
|
|
|
| 45 |
}
|
| 46 |
TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
|
| 47 |
FINAL_CITATION_LEDGER_VERSION = 'resolved-final-ledger-v1'
|
| 48 |
+
ENGLISH_SECTION_SEAL_VERSION = 'english-section-integrity-seal-v1'
|
| 49 |
|
| 50 |
|
| 51 |
def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
|
|
|
|
| 930 |
)
|
| 931 |
return value, list(diagnostics or [])
|
| 932 |
|
| 933 |
+
@staticmethod
|
| 934 |
+
def _section_integrity_hash(value: str) -> str:
|
| 935 |
+
exact = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
| 936 |
+
return hashlib.sha256(exact.encode("utf-8")).hexdigest()
|
| 937 |
+
|
| 938 |
+
@classmethod
|
| 939 |
+
def _build_section_integrity_seal(cls, section_key: str, section_title: str, section_text: str) -> dict:
|
| 940 |
+
return {
|
| 941 |
+
"version": ENGLISH_SECTION_SEAL_VERSION,
|
| 942 |
+
"section_key": str(section_key or ""),
|
| 943 |
+
"section_title": str(section_title or ""),
|
| 944 |
+
"sha256": cls._section_integrity_hash(section_text),
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
@staticmethod
|
| 948 |
+
def _replace_top_level_section(manuscript: str, title: str, replacement: str) -> str:
|
| 949 |
+
value = str(manuscript or "")
|
| 950 |
+
pattern = re.compile(
|
| 951 |
+
rf'(?ms)^#\s+{re.escape(str(title))}\s*$\n.*?(?=^#\s+[^#].*?$|\Z)'
|
| 952 |
+
)
|
| 953 |
+
matches = list(pattern.finditer(value))
|
| 954 |
+
if len(matches) != 1:
|
| 955 |
+
raise RuntimeError(f"top_level_section_replace_count_invalid:{title}:{len(matches)}")
|
| 956 |
+
match = matches[0]
|
| 957 |
+
rendered = str(replacement or "").strip()
|
| 958 |
+
return (value[:match.start()] + rendered + "\n\n" + value[match.end():].lstrip("\n")).strip() + "\n\n"
|
| 959 |
+
|
| 960 |
+
@classmethod
|
| 961 |
+
def _validate_section_integrity_seal(cls, seal: dict, section_text: str, section_title: str) -> None:
|
| 962 |
+
record = dict(seal or {})
|
| 963 |
+
if record.get("version") != ENGLISH_SECTION_SEAL_VERSION:
|
| 964 |
+
raise RuntimeError(f"english_section_integrity_seal_version_invalid:{section_title}")
|
| 965 |
+
expected = str(record.get("sha256") or "")
|
| 966 |
+
actual = cls._section_integrity_hash(section_text)
|
| 967 |
+
if not expected or expected != actual:
|
| 968 |
+
raise RuntimeError(f"english_section_integrity_seal_mismatch:{section_title}")
|
| 969 |
+
|
| 970 |
@staticmethod
|
| 971 |
def _replace_keywords(manuscript: str, keywords) -> str:
|
| 972 |
value = str(manuscript or '')
|
|
|
|
| 1482 |
except PipelineCancelledError:
|
| 1483 |
raise
|
| 1484 |
except Exception as error:
|
| 1485 |
+
# Persist repair state for both intermediate and strict final gates.
|
| 1486 |
+
# The caller owns checkpoint authority and decides whether to stop.
|
| 1487 |
code_method = getattr(agent, 'translation_error_code', None)
|
| 1488 |
error_code = str(code_method(error) if callable(code_method) else 'TRANSLATION_PENDING')
|
| 1489 |
updated_sections[unit_key] = {
|
|
|
|
| 1504 |
'deferred_at': datetime.now(timezone.utc).isoformat(),
|
| 1505 |
}
|
| 1506 |
logging.warning(
|
| 1507 |
+
'Translation repair pending | unit=%s final_gate=%s code=%s error=%s',
|
| 1508 |
+
unit_name, bool(final_gate), error_code, type(error).__name__,
|
| 1509 |
)
|
| 1510 |
self._translation_progress_sections = dict(updated_sections)
|
| 1511 |
self._translation_progress_inflight.discard(str(unit_key))
|
|
|
|
| 1960 |
state["english_finalized"] = False
|
| 1961 |
state["translation_complete"] = False
|
| 1962 |
state["package_validated"] = False
|
| 1963 |
+
if state.get("english_finalized") and state.get("english_section_seal_version") != ENGLISH_SECTION_SEAL_VERSION:
|
| 1964 |
+
# R2 compatibility: upgrade old completed English sections locally.
|
| 1965 |
+
# No writing-model call, new task, or new quota reservation is allowed.
|
| 1966 |
+
logging.warning("English Section Integrity Seal contract advanced; re-validating frozen completed sections locally.")
|
| 1967 |
+
state["english_finalized"] = False
|
| 1968 |
+
state["translation_complete"] = False
|
| 1969 |
+
state["package_validated"] = False
|
| 1970 |
|
| 1971 |
user_lit_context = str(state.get("user_lit_context") or "")
|
| 1972 |
user_data_md = str(state.get("user_data_md") or "")
|
|
|
|
| 2006 |
)
|
| 2007 |
self._checkpoint("evidence_ready", state, 6)
|
| 2008 |
|
| 2009 |
+
if mode == "amj":
|
| 2010 |
+
current_quant_ledger = dict(data_metadata.get("quantitative_evidence_ledger") or {})
|
| 2011 |
+
expected_quant_version = str(getattr(quant_engine, "QUANTITATIVE_EVIDENCE_LEDGER_VERSION", ""))
|
| 2012 |
+
if current_quant_ledger.get("version") != expected_quant_version:
|
| 2013 |
+
data_metadata["quantitative_evidence_ledger"] = quant_engine.build_quantitative_evidence_ledger(
|
| 2014 |
+
data_metadata, user_data_md
|
| 2015 |
+
)
|
| 2016 |
+
state["data_metadata"] = data_metadata
|
| 2017 |
+
logging.warning(
|
| 2018 |
+
"Quantitative Evidence Ledger contract advanced; rebuilt locally version=%s",
|
| 2019 |
+
expected_quant_version,
|
| 2020 |
+
)
|
| 2021 |
+
self._checkpoint("quantitative_evidence_ledger_upgraded", state, 6)
|
| 2022 |
+
|
| 2023 |
self._qualitative_quote_sources = (
|
| 2024 |
self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
|
| 2025 |
)
|
|
|
|
| 2286 |
"citation_unit_section_map": citation_unit_section_map,
|
| 2287 |
"citation_validated_tags_by_unit": {},
|
| 2288 |
"citation_required_claim_counts_by_unit": {},
|
| 2289 |
+
"section_integrity_seals": {},
|
| 2290 |
+
"english_section_seal_version": ENGLISH_SECTION_SEAL_VERSION,
|
| 2291 |
"research_logic_contract": research_logic_contract,
|
| 2292 |
"working_abstract": working_abstract,
|
| 2293 |
"working_keywords": working_keywords,
|
|
|
|
| 2332 |
str(name): list(tags or [])
|
| 2333 |
for name, tags in dict(state.get("citation_validated_tags_by_unit") or {}).items()
|
| 2334 |
}
|
| 2335 |
+
section_integrity_seals = {
|
| 2336 |
+
str(key): dict(record or {})
|
| 2337 |
+
for key, record in dict(state.get("section_integrity_seals") or {}).items()
|
| 2338 |
+
if isinstance(record, dict)
|
| 2339 |
+
}
|
| 2340 |
if not citation_minimums:
|
| 2341 |
citation_minimums, citation_reserve_targets = self._adaptive_citation_requirements(
|
| 2342 |
adaptive_structure, mode
|
|
|
|
| 2383 |
if self._preview_target_text == '':
|
| 2384 |
self._preview_target_text = str(state.get('full_draft_translated') or '')
|
| 2385 |
|
| 2386 |
+
# P0-R1.1-R2 compatibility migration: old checkpoints predate Section
|
| 2387 |
+
# Integrity Seals. Reconcile only already-completed English sections with
|
| 2388 |
+
# deterministic local contracts, seal them, and invalidate target cache
|
| 2389 |
+
# only when the exact English section changes. Never call the writer.
|
| 2390 |
+
seal_migration = False
|
| 2391 |
+
seal_text_changed = False
|
| 2392 |
+
for section_key, section_title in self._unique_top_level_sections(adaptive_structure):
|
| 2393 |
+
if not self._section_is_complete(adaptive_structure, completed_sections, section_key):
|
| 2394 |
+
continue
|
| 2395 |
+
section_text = self._extract_top_level_section(full_draft_en, section_title)
|
| 2396 |
+
if not section_text:
|
| 2397 |
+
continue
|
| 2398 |
+
existing_seal = dict(section_integrity_seals.get(section_key) or {})
|
| 2399 |
+
if (
|
| 2400 |
+
existing_seal.get("version") == ENGLISH_SECTION_SEAL_VERSION
|
| 2401 |
+
and existing_seal.get("sha256") == self._section_integrity_hash(section_text)
|
| 2402 |
+
):
|
| 2403 |
+
continue
|
| 2404 |
+
migrated_text, enum_rows = agent.repair_and_validate_enumeration_contract(
|
| 2405 |
+
section_text, section_name=f"Resume sealed section: {section_title}"
|
| 2406 |
+
)
|
| 2407 |
+
migrated_text, fact_rows = self._repair_validate_study_fact_contract(
|
| 2408 |
+
mode, agent, quant_engine, migrated_text, data_metadata,
|
| 2409 |
+
section_name=f"Resume sealed section: {section_title}",
|
| 2410 |
+
)
|
| 2411 |
+
if migrated_text.strip() != section_text.strip():
|
| 2412 |
+
full_draft_en = self._replace_top_level_section(
|
| 2413 |
+
full_draft_en, section_title, migrated_text
|
| 2414 |
+
)
|
| 2415 |
+
translation_sections.pop(section_key, None)
|
| 2416 |
+
seal_text_changed = True
|
| 2417 |
+
quality_diagnostics.append({
|
| 2418 |
+
"scope":"english_section_seal_migration", "name":section_title,
|
| 2419 |
+
"status":"deterministic_repair", "count":len(enum_rows) + len(fact_rows),
|
| 2420 |
+
"examples":(list(enum_rows or []) + list(fact_rows or []))[:3],
|
| 2421 |
+
})
|
| 2422 |
+
section_text = self._extract_top_level_section(full_draft_en, section_title)
|
| 2423 |
+
section_integrity_seals[section_key] = self._build_section_integrity_seal(
|
| 2424 |
+
section_key, section_title, section_text
|
| 2425 |
+
)
|
| 2426 |
+
seal_migration = True
|
| 2427 |
+
if seal_migration:
|
| 2428 |
+
# Rebuild mutable manuscript memory only from sealed English text so
|
| 2429 |
+
# rejected/raw claims cannot propagate into later sections.
|
| 2430 |
+
for memory_key in ("hypotheses", "variables", "model_summary", "method_summary"):
|
| 2431 |
+
agent.global_memory[memory_key] = ""
|
| 2432 |
+
last_sealed = ""
|
| 2433 |
+
for section_key, section_title in self._unique_top_level_sections(adaptive_structure):
|
| 2434 |
+
if not self._section_is_complete(adaptive_structure, completed_sections, section_key):
|
| 2435 |
+
continue
|
| 2436 |
+
sealed_text = self._extract_top_level_section(full_draft_en, section_title)
|
| 2437 |
+
if sealed_text:
|
| 2438 |
+
agent._extract_memory_from_text(sealed_text, section_title)
|
| 2439 |
+
last_sealed = sealed_text
|
| 2440 |
+
if last_sealed:
|
| 2441 |
+
context_tail = " ".join(last_sealed.split()[-200:])
|
| 2442 |
+
state.update({
|
| 2443 |
+
"full_draft_en": full_draft_en,
|
| 2444 |
+
"translation_sections": translation_sections,
|
| 2445 |
+
"section_integrity_seals": section_integrity_seals,
|
| 2446 |
+
"english_section_seal_version": ENGLISH_SECTION_SEAL_VERSION,
|
| 2447 |
+
"context_tail": context_tail,
|
| 2448 |
+
"quality_diagnostics": quality_diagnostics,
|
| 2449 |
+
"agent_state": agent.export_state(),
|
| 2450 |
+
})
|
| 2451 |
+
if seal_text_changed:
|
| 2452 |
+
state["full_draft_translated"] = self._assemble_partial_translation(
|
| 2453 |
+
translation_sections, adaptive_structure, include_front=True
|
| 2454 |
+
)
|
| 2455 |
+
self._checkpoint("english_section_seal_migrated", state, 15)
|
| 2456 |
+
|
| 2457 |
# Target-language work remains Executor-owned but no longer blocks the
|
| 2458 |
# English writer. A dedicated single translation agent/worker keeps
|
| 2459 |
# glossary order deterministic and avoids sharing mutable writer state.
|
|
|
|
| 2906 |
en_resolved = recovered
|
| 2907 |
used_after = set(agent.used_citations)
|
| 2908 |
survived = used_after - used_before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2909 |
if mode == "asq" and section_id == "findings":
|
| 2910 |
en_resolved = self._render_planned_qualitative_quotes(
|
| 2911 |
en_resolved,
|
|
|
|
| 2913 |
data_metadata=data_metadata,
|
| 2914 |
quote_invocation_plan=quote_invocation_plan,
|
| 2915 |
)
|
| 2916 |
+
|
| 2917 |
+
# P0-R1.1-R2: every mutating deterministic operation must complete
|
| 2918 |
+
# before the Evidence Fact contract seals the English unit.
|
| 2919 |
+
en_resolved, enumeration_rows = agent.repair_and_validate_enumeration_contract(
|
| 2920 |
+
en_resolved, section_name=display_name
|
| 2921 |
+
)
|
| 2922 |
+
if enumeration_rows:
|
| 2923 |
+
logging.warning(
|
| 2924 |
+
"Structural enumeration repaired | unit=%s count=%s details=%s",
|
| 2925 |
+
display_name, len(enumeration_rows), enumeration_rows[:3],
|
| 2926 |
+
)
|
| 2927 |
+
quality_diagnostics.append({
|
| 2928 |
+
"scope":"structural_enumeration", "name":display_name,
|
| 2929 |
+
"status":"deterministic_repair", "count":len(enumeration_rows),
|
| 2930 |
+
"examples":enumeration_rows[:3],
|
| 2931 |
+
})
|
| 2932 |
+
en_resolved, duplicate_rows = self._remove_severe_cross_unit_duplicates(en_resolved, full_draft_en)
|
| 2933 |
+
if duplicate_rows:
|
| 2934 |
+
logging.warning("Severe cross-unit redundancy removed | unit=%s count=%s max_similarity=%s", display_name, len(duplicate_rows), max(row["similarity"] for row in duplicate_rows))
|
| 2935 |
+
quality_diagnostics.append({"scope":"manuscript_redundancy","name":display_name,"status":"bounded_duplicate_removed","count":len(duplicate_rows),"examples":duplicate_rows[:3]})
|
| 2936 |
+
en_resolved, sentence_echo_rows = agent.remove_severe_cross_unit_sentence_echoes(
|
| 2937 |
+
en_resolved, full_draft_en
|
| 2938 |
+
)
|
| 2939 |
+
if sentence_echo_rows:
|
| 2940 |
+
logging.warning(
|
| 2941 |
+
"Severe cross-unit sentence echo removed | unit=%s count=%s max_similarity=%s",
|
| 2942 |
+
display_name, len(sentence_echo_rows),
|
| 2943 |
+
max(row["similarity"] for row in sentence_echo_rows),
|
| 2944 |
+
)
|
| 2945 |
+
quality_diagnostics.append({
|
| 2946 |
+
"scope":"manuscript_sentence_echo", "name":display_name,
|
| 2947 |
+
"status":"bounded_duplicate_removed", "count":len(sentence_echo_rows),
|
| 2948 |
+
"examples":sentence_echo_rows[:3],
|
| 2949 |
+
})
|
| 2950 |
+
|
| 2951 |
+
# Evidence Fact repair/validation is intentionally LAST. Once this
|
| 2952 |
+
# passes, no later operation may mutate this English unit.
|
| 2953 |
if mode == "asq":
|
| 2954 |
en_resolved, study_fact_rows = agent.repair_qualitative_study_fact_contract(
|
| 2955 |
en_resolved, data_metadata, section_name=display_name
|
|
|
|
| 2986 |
quant_engine.validate_quantitative_study_fact_contract(
|
| 2987 |
en_resolved, data_metadata, section_name=display_name
|
| 2988 |
)
|
| 2989 |
+
|
| 2990 |
+
# Recompute citation survival/claim demand from the exact text that
|
| 2991 |
+
# will be sealed, not from an earlier pre-cleanup intermediate.
|
| 2992 |
+
if not is_quarantined and section_id in self._citation_eligible_section_ids(mode):
|
| 2993 |
+
final_survived = set()
|
| 2994 |
+
for tag in set(survived or set()):
|
| 2995 |
+
marker = str((agent.citation_map.get(tag) or {}).get("in_text") or "").strip()
|
| 2996 |
+
if marker and marker in en_resolved:
|
| 2997 |
+
final_survived.add(tag)
|
| 2998 |
+
survived = final_survived
|
| 2999 |
+
citation_required_claim_counts_by_unit[display_name] = agent.count_external_literature_claims(
|
| 3000 |
+
en_resolved, section_name=display_name
|
| 3001 |
)
|
| 3002 |
+
if assigned_tag_list:
|
| 3003 |
+
citation_validated_tags_by_unit[display_name] = sorted(
|
| 3004 |
+
survived, key=agent._citation_tag_number
|
| 3005 |
+
)
|
| 3006 |
+
logging.info(
|
| 3007 |
+
"Top-level citation accumulation: unit=%s section=%s validated=%s external_claims=%s",
|
| 3008 |
+
display_name, citation_unit_section_map.get(display_name, section_id),
|
| 3009 |
+
len(survived), citation_required_claim_counts_by_unit[display_name],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3010 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3011 |
full_draft_en += en_resolved + "\n\n"
|
| 3012 |
if assigned_tag_list:
|
| 3013 |
logging.info(
|
|
|
|
| 3015 |
display_name, len(assigned_tag_list), len(survived),
|
| 3016 |
)
|
| 3017 |
self._publish_preview(full_draft_en, display_name, force=True)
|
| 3018 |
+
# Only sealed/reconciled prose may feed subsequent manuscript memory.
|
| 3019 |
+
agent._extract_memory_from_text(en_resolved, sec_title)
|
| 3020 |
+
context_tail = " ".join(en_resolved.split()[-200:])
|
| 3021 |
actual_words = self._english_count_for_target(en_resolved)
|
| 3022 |
if not bool(sec.get("exclude_from_word_budget")):
|
| 3023 |
accumulated_deviation += actual_words - adjusted_target
|
|
|
|
| 3028 |
section_key = str(sec.get('section_id') or sec_title)
|
| 3029 |
if self._section_is_complete(adaptive_structure, completed_sections, section_key):
|
| 3030 |
frozen_section = self._extract_top_level_section(full_draft_en, sec_title)
|
| 3031 |
+
if not frozen_section:
|
| 3032 |
+
raise RuntimeError(f"english_section_seal_source_missing:{sec_title}")
|
| 3033 |
+
section_integrity_seals[section_key] = self._build_section_integrity_seal(
|
| 3034 |
+
section_key, sec_title, frozen_section
|
| 3035 |
+
)
|
| 3036 |
self._enqueue_translation_unit(
|
| 3037 |
translation_agent,
|
| 3038 |
unit_key=section_key, unit_name=sec_title, source_text=frozen_section,
|
|
|
|
| 3063 |
"citation_unit_section_map": citation_unit_section_map,
|
| 3064 |
"citation_required_claim_counts_by_unit": citation_required_claim_counts_by_unit,
|
| 3065 |
"citation_validated_tags_by_unit": citation_validated_tags_by_unit,
|
| 3066 |
+
"section_integrity_seals": section_integrity_seals,
|
| 3067 |
+
"english_section_seal_version": ENGLISH_SECTION_SEAL_VERSION,
|
| 3068 |
"agent_state": agent.export_state(),
|
| 3069 |
"data_metadata": data_metadata,
|
| 3070 |
}
|
|
|
|
| 3080 |
section_order_for_fact = self._unique_top_level_sections(adaptive_structure)
|
| 3081 |
if not section_order_for_fact:
|
| 3082 |
raise RuntimeError("final_study_fact_section_plan_missing")
|
| 3083 |
+
# Final English validation is validation-only. Every mutable repair
|
| 3084 |
+
# was completed before the top-level section received its integrity
|
| 3085 |
+
# seal; finalization must never repair the whole manuscript again.
|
| 3086 |
+
for section_key, section_title in section_order_for_fact:
|
| 3087 |
+
sealed_section = self._extract_top_level_section(full_draft_en, section_title)
|
| 3088 |
+
if not sealed_section:
|
| 3089 |
+
raise RuntimeError(f"final_english_section_missing:{section_title}")
|
| 3090 |
+
seal = dict(section_integrity_seals.get(section_key) or {})
|
| 3091 |
+
self._validate_section_integrity_seal(seal, sealed_section, section_title)
|
| 3092 |
+
try:
|
| 3093 |
+
if mode == "asq":
|
| 3094 |
+
agent.validate_qualitative_study_fact_contract(
|
| 3095 |
+
sealed_section, data_metadata, section_name=section_title
|
| 3096 |
+
)
|
| 3097 |
+
elif mode == "amj":
|
| 3098 |
+
quant_engine.validate_quantitative_study_fact_contract(
|
| 3099 |
+
sealed_section, data_metadata, section_name=section_title
|
| 3100 |
+
)
|
| 3101 |
+
except Exception as exc:
|
| 3102 |
+
prefix = (
|
| 3103 |
+
"final_qualitative_study_fact_contract_failed"
|
| 3104 |
+
if mode == "asq" else "final_quantitative_study_fact_contract_failed"
|
| 3105 |
)
|
| 3106 |
+
raise RuntimeError(f"{prefix}:{section_title}") from exc
|
| 3107 |
+
enum_checked, _enum_rows = agent.repair_and_validate_enumeration_contract(
|
| 3108 |
+
sealed_section, section_name=f"Final sealed section: {section_title}"
|
| 3109 |
)
|
| 3110 |
+
if enum_checked.strip() != sealed_section.strip():
|
| 3111 |
+
raise RuntimeError(f"final_enumeration_contract_failed:{section_title}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3112 |
# Validate the completed body while the Working Abstract is still in
|
| 3113 |
# place. Final front matter must not be generated until all body
|
| 3114 |
# sections, citation cleanup, and any Discussion rebalance are frozen.
|
|
|
|
| 3292 |
"citation_unit_section_map": citation_unit_section_map,
|
| 3293 |
"citation_required_claim_counts_by_unit": citation_required_claim_counts_by_unit,
|
| 3294 |
"citation_validated_tags_by_unit": citation_validated_tags_by_unit,
|
| 3295 |
+
"section_integrity_seals": section_integrity_seals,
|
| 3296 |
+
"english_section_seal_version": ENGLISH_SECTION_SEAL_VERSION,
|
| 3297 |
"quality_diagnostics": quality_diagnostics,
|
| 3298 |
"english_finalized": True,
|
| 3299 |
"translation_complete": False,
|
|
|
|
| 3345 |
'agent_state': agent.export_state(),
|
| 3346 |
})
|
| 3347 |
pct = min(96, 94 + int(index * 2 / max(1, len(final_units))))
|
| 3348 |
+
current_translation = dict(translation_sections.get(unit_key) or {})
|
| 3349 |
+
blocking = (
|
| 3350 |
+
current_translation.get('qa_status') not in {'passed', 'passed_with_warning'}
|
| 3351 |
+
or not current_translation.get('translated_text')
|
| 3352 |
+
)
|
| 3353 |
+
if blocking:
|
| 3354 |
+
state['translation_blocking_unit'] = str(unit_key)
|
| 3355 |
+
state['translation_blocking_unit_name'] = str(unit_name)
|
| 3356 |
+
state['translation_blocking_error_code'] = str(current_translation.get('error_code') or 'TRANSLATION_PENDING')
|
| 3357 |
+
self._checkpoint(
|
| 3358 |
+
f'translation_{unit_key}_repair_pending', state, pct,
|
| 3359 |
+
display_node=f'translation_repair_pending:{index}:{len(final_units)}',
|
| 3360 |
+
)
|
| 3361 |
+
# Stop at the first blocking unit. The durable claim state is
|
| 3362 |
+
# now checkpointed, so resume continues exactly here instead
|
| 3363 |
+
# of spending tokens on later units or repeating prior repair.
|
| 3364 |
+
raise RuntimeError('translation_final_gate_unresolved:' + str(unit_key))
|
| 3365 |
+
state.pop('translation_blocking_unit', None)
|
| 3366 |
+
state.pop('translation_blocking_unit_name', None)
|
| 3367 |
+
state.pop('translation_blocking_error_code', None)
|
| 3368 |
self._checkpoint(
|
| 3369 |
f'translation_{unit_key}', state, pct,
|
| 3370 |
display_node=f'translation_final_gate:{index}:{len(final_units)}',
|
core/pipeline_agent.py
CHANGED
|
@@ -3430,6 +3430,13 @@ INPUT:
|
|
| 3430 |
|
| 3431 |
@classmethod
|
| 3432 |
def _nearest_qual_source_family(cls, sentence, actor_start):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3433 |
text = str(sentence or "")
|
| 3434 |
markers = []
|
| 3435 |
patterns = {
|
|
@@ -3439,22 +3446,75 @@ INPUT:
|
|
| 3439 |
"log": r"\b(?:usage|interaction|system)\s+logs?\b|\blog\s+data\b",
|
| 3440 |
"document": r"\bdocuments?\b|\barchival\s+(?:records?|materials?)\b|\brecords?\b",
|
| 3441 |
}
|
|
|
|
| 3442 |
for family, pattern in patterns.items():
|
| 3443 |
for match in re.finditer(pattern, text, flags=re.I):
|
| 3444 |
-
markers.append((
|
| 3445 |
if not markers:
|
| 3446 |
return ""
|
| 3447 |
-
|
| 3448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3449 |
|
| 3450 |
@classmethod
|
| 3451 |
def repair_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 3452 |
-
"""
|
| 3453 |
|
| 3454 |
-
The frozen Study Evidence Ledger owns actor/data-source
|
| 3455 |
-
|
| 3456 |
-
|
| 3457 |
-
study-owned procedure claim.
|
| 3458 |
"""
|
| 3459 |
value = str(text or "")
|
| 3460 |
facts = dict((metadata or {}).get("study_facts") or {})
|
|
@@ -3466,6 +3526,7 @@ INPUT:
|
|
| 3466 |
}
|
| 3467 |
allowed_by_actor = {}
|
| 3468 |
canonical_relations = []
|
|
|
|
| 3469 |
for row in relations:
|
| 3470 |
role = re.sub(r"\s+", " ", str(row.get("actor_role") or "")).strip()
|
| 3471 |
source = re.sub(r"\s+", " ", str(row.get("data_source") or "")).strip()
|
|
@@ -3475,80 +3536,142 @@ INPUT:
|
|
| 3475 |
canonical_relations.append((role, source, row.get("count")))
|
| 3476 |
for variant in cls._qual_actor_variants(role):
|
| 3477 |
allowed_by_actor.setdefault(variant, set()).add(family)
|
|
|
|
| 3478 |
|
| 3479 |
-
study_owned = re.compile(
|
| 3480 |
-
r"\b(?:we|our\s+study|this\s+study|the\s+study|research\s+team|participants?|respondents?)\b|"
|
| 3481 |
-
r"\b(?:was|were)\s+(?:conducted|performed|used|maintained|obtained|recorded|anonymized|anonymised)\b",
|
| 3482 |
-
flags=re.I,
|
| 3483 |
-
)
|
| 3484 |
procedure_patterns = {
|
| 3485 |
"trustworthiness": r"\bmember\s+check(?:ing|s)?\b|\bpeer\s+debrief(?:ing|ed)?\b|\baudit\s+trail\b|\bexternal\s+audit\b",
|
| 3486 |
"ethics": r"\binformed\s+consent\b|\bethics?\s+approval\b|\binstitutional\s+review\s+board\b|\bIRB\b|\bapproval\s+(?:number|code)\b",
|
| 3487 |
"reflexivity": r"\bfield\s+journal\b|\breflexive\s+journal\b|\bpositionality\s+(?:statement|memo)\b|\breflexive\s+memo(?:s)?\b",
|
| 3488 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3489 |
spans = cls._sentence_spans(value)
|
| 3490 |
replacements = []
|
| 3491 |
diagnostics = []
|
| 3492 |
for start, end, sentence in spans:
|
| 3493 |
core = str(sentence or "")
|
| 3494 |
lower = core.casefold()
|
| 3495 |
-
if not core.strip():
|
| 3496 |
continue
|
| 3497 |
removed = False
|
| 3498 |
-
|
| 3499 |
-
|
| 3500 |
-
|
| 3501 |
-
|
| 3502 |
-
|
| 3503 |
-
|
| 3504 |
-
|
| 3505 |
-
|
| 3506 |
-
|
| 3507 |
-
|
| 3508 |
-
|
| 3509 |
-
|
| 3510 |
-
|
| 3511 |
-
|
| 3512 |
-
|
| 3513 |
-
|
| 3514 |
-
|
| 3515 |
-
|
| 3516 |
-
|
| 3517 |
-
|
| 3518 |
-
|
| 3519 |
-
|
| 3520 |
-
|
| 3521 |
-
|
| 3522 |
-
|
| 3523 |
-
|
| 3524 |
-
|
| 3525 |
-
|
| 3526 |
-
|
| 3527 |
-
|
| 3528 |
-
|
| 3529 |
-
|
| 3530 |
-
|
| 3531 |
-
|
| 3532 |
-
|
| 3533 |
-
break
|
| 3534 |
if removed or not canonical_relations:
|
| 3535 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3536 |
for actor_variant, allowed_families in allowed_by_actor.items():
|
| 3537 |
-
|
| 3538 |
-
|
| 3539 |
-
|
| 3540 |
-
|
| 3541 |
-
|
| 3542 |
-
|
| 3543 |
-
|
| 3544 |
-
|
| 3545 |
-
|
| 3546 |
-
|
| 3547 |
-
|
| 3548 |
-
|
| 3549 |
-
|
| 3550 |
-
|
| 3551 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3552 |
break
|
| 3553 |
if replacements:
|
| 3554 |
for start, end, replacement in sorted(replacements, reverse=True):
|
|
|
|
| 3430 |
|
| 3431 |
@classmethod
|
| 3432 |
def _nearest_qual_source_family(cls, sentence, actor_start):
|
| 3433 |
+
"""Resolve the data source grammatically associated with one actor.
|
| 3434 |
+
|
| 3435 |
+
Prefer the last source phrase before the actor (``interviews with X``),
|
| 3436 |
+
then fall back to the nearest following source (``X was interviewed``).
|
| 3437 |
+
Absolute-distance matching can cross an ``and`` boundary and wrongly
|
| 3438 |
+
pair X with the next actor's source.
|
| 3439 |
+
"""
|
| 3440 |
text = str(sentence or "")
|
| 3441 |
markers = []
|
| 3442 |
patterns = {
|
|
|
|
| 3446 |
"log": r"\b(?:usage|interaction|system)\s+logs?\b|\blog\s+data\b",
|
| 3447 |
"document": r"\bdocuments?\b|\barchival\s+(?:records?|materials?)\b|\brecords?\b",
|
| 3448 |
}
|
| 3449 |
+
position = int(actor_start)
|
| 3450 |
for family, pattern in patterns.items():
|
| 3451 |
for match in re.finditer(pattern, text, flags=re.I):
|
| 3452 |
+
markers.append((match.start(), match.end(), family))
|
| 3453 |
if not markers:
|
| 3454 |
return ""
|
| 3455 |
+
preceding = [row for row in markers if row[1] <= position]
|
| 3456 |
+
if preceding:
|
| 3457 |
+
start, end, family = max(preceding, key=lambda row: row[1])
|
| 3458 |
+
# A source more than one short clause away is unlikely to own actor.
|
| 3459 |
+
if position - end <= 120:
|
| 3460 |
+
return family
|
| 3461 |
+
following = [row for row in markers if row[0] >= position]
|
| 3462 |
+
if following:
|
| 3463 |
+
return min(following, key=lambda row: row[0] - position)[2]
|
| 3464 |
+
return ""
|
| 3465 |
+
|
| 3466 |
+
@staticmethod
|
| 3467 |
+
def _has_external_literature_marker(sentence):
|
| 3468 |
+
value = str(sentence or "")
|
| 3469 |
+
if re.search(r"\bCITE-\d+\b", value, flags=re.I):
|
| 3470 |
+
return True
|
| 3471 |
+
if re.search(
|
| 3472 |
+
r"\((?=[^()\n]*[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+(?:\s+(?:&\s+[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+|et\s+al\.))?\s*,\s*(?:19|20)\d{2}[a-z]?\b)[^()\n]+\)",
|
| 3473 |
+
value, flags=re.I,
|
| 3474 |
+
):
|
| 3475 |
+
return True
|
| 3476 |
+
if re.search(r"\b[A-Z][A-Za-z'’.-]+\s+et\s+al\.\s*\((?:19|20)\d{2}[a-z]?\)", value):
|
| 3477 |
+
return True
|
| 3478 |
+
return False
|
| 3479 |
+
|
| 3480 |
+
@classmethod
|
| 3481 |
+
def _is_study_owned_qualitative_claim(cls, sentence, section_name=""):
|
| 3482 |
+
"""Conservatively identify only this manuscript's empirical/procedural claims.
|
| 3483 |
+
|
| 3484 |
+
External literature, quoted participant speech, and generic methodological
|
| 3485 |
+
statements are immutable to the Study Evidence Ledger. False negatives are
|
| 3486 |
+
safer here than rewriting another study's facts.
|
| 3487 |
+
"""
|
| 3488 |
+
value = str(sentence or "")
|
| 3489 |
+
stripped = value.strip()
|
| 3490 |
+
if not stripped or stripped.startswith(">"):
|
| 3491 |
+
return False
|
| 3492 |
+
if re.match(r'^["“‘\']', stripped) or re.search(r"\bQUOTE-\d+\b", value, flags=re.I):
|
| 3493 |
+
return False
|
| 3494 |
+
if cls._has_external_literature_marker(value):
|
| 3495 |
+
return False
|
| 3496 |
+
if re.search(r"(?i)\b(?:prior|previous|earlier|existing)\s+(?:research|stud(?:y|ies)|literature)\b", value):
|
| 3497 |
+
return False
|
| 3498 |
+
strong = re.compile(
|
| 3499 |
+
r"(?i)\b(?:we|our\s+study|this\s+study|the\s+present\s+study|the\s+study|"
|
| 3500 |
+
r"research\s+team|our\s+data|our\s+analysis|our\s+interviews?|our\s+focus\s+groups?|"
|
| 3501 |
+
r"our\s+observations?|data\s+collection|the\s+empirical\s+base|the\s+sample|"
|
| 3502 |
+
r"(?:interview|focus\s+group|observation)\s+participants?|"
|
| 3503 |
+
r"participants?\s+(?:in\s+this\s+study\s+)?(?:were|took|participated)|"
|
| 3504 |
+
r"respondents?\s+(?:in\s+this\s+study\s+)?(?:were|took|participated)|"
|
| 3505 |
+
r"interviews?\s+(?:were|was)\s+conducted|focus\s+groups?\s+(?:were|was)\s+conducted|"
|
| 3506 |
+
r"observations?\s+(?:were|was)\s+(?:conducted|recorded))\b|"
|
| 3507 |
+
r"\b(?:was|were)\s+(?:conducted|performed|used|maintained|obtained|recorded|anonymized|anonymised)\b"
|
| 3508 |
+
)
|
| 3509 |
+
return bool(strong.search(value))
|
| 3510 |
|
| 3511 |
@classmethod
|
| 3512 |
def repair_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 3513 |
+
"""Repair only high-confidence *study-owned* qualitative fact violations.
|
| 3514 |
|
| 3515 |
+
The frozen Study Evidence Ledger owns this study's actor/data-source
|
| 3516 |
+
relations and explicitly performed procedures. External literature and
|
| 3517 |
+
participant quotations are never rewritten by this deterministic layer.
|
|
|
|
| 3518 |
"""
|
| 3519 |
value = str(text or "")
|
| 3520 |
facts = dict((metadata or {}).get("study_facts") or {})
|
|
|
|
| 3526 |
}
|
| 3527 |
allowed_by_actor = {}
|
| 3528 |
canonical_relations = []
|
| 3529 |
+
canonical_by_actor = {}
|
| 3530 |
for row in relations:
|
| 3531 |
role = re.sub(r"\s+", " ", str(row.get("actor_role") or "")).strip()
|
| 3532 |
source = re.sub(r"\s+", " ", str(row.get("data_source") or "")).strip()
|
|
|
|
| 3536 |
canonical_relations.append((role, source, row.get("count")))
|
| 3537 |
for variant in cls._qual_actor_variants(role):
|
| 3538 |
allowed_by_actor.setdefault(variant, set()).add(family)
|
| 3539 |
+
canonical_by_actor.setdefault(variant, []).append((role, source, family, row.get("count")))
|
| 3540 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3541 |
procedure_patterns = {
|
| 3542 |
"trustworthiness": r"\bmember\s+check(?:ing|s)?\b|\bpeer\s+debrief(?:ing|ed)?\b|\baudit\s+trail\b|\bexternal\s+audit\b",
|
| 3543 |
"ethics": r"\binformed\s+consent\b|\bethics?\s+approval\b|\binstitutional\s+review\s+board\b|\bIRB\b|\bapproval\s+(?:number|code)\b",
|
| 3544 |
"reflexivity": r"\bfield\s+journal\b|\breflexive\s+journal\b|\bpositionality\s+(?:statement|memo)\b|\breflexive\s+memo(?:s)?\b",
|
| 3545 |
}
|
| 3546 |
+
source_patterns = {
|
| 3547 |
+
"interview": r"\b(?:semi[- ]structured\s+)?interviews?\b|\binterviewed\b|\binterview\s+participants?\b",
|
| 3548 |
+
"focus_group": r"\bfocus\s+groups?\b",
|
| 3549 |
+
"observation": r"\b(?:field\s+)?observations?\b|\bobserved\b",
|
| 3550 |
+
"log": r"\b(?:usage|interaction|system)\s+logs?\b|\blog\s+data\b",
|
| 3551 |
+
"document": r"\bdocuments?\b|\barchival\s+(?:records?|materials?)\b|\brecords?\b",
|
| 3552 |
+
}
|
| 3553 |
spans = cls._sentence_spans(value)
|
| 3554 |
replacements = []
|
| 3555 |
diagnostics = []
|
| 3556 |
for start, end, sentence in spans:
|
| 3557 |
core = str(sentence or "")
|
| 3558 |
lower = core.casefold()
|
| 3559 |
+
if not core.strip() or not cls._is_study_owned_qualitative_claim(core, section_name):
|
| 3560 |
continue
|
| 3561 |
removed = False
|
| 3562 |
+
for category, pattern in procedure_patterns.items():
|
| 3563 |
+
procedure_match = re.search(pattern, core, flags=re.I)
|
| 3564 |
+
if not procedure_match:
|
| 3565 |
+
continue
|
| 3566 |
+
observed = procedure_match.group(0).casefold()
|
| 3567 |
+
if "member check" in observed:
|
| 3568 |
+
key = "member check"
|
| 3569 |
+
elif "peer debrief" in observed:
|
| 3570 |
+
key = "peer debrief"
|
| 3571 |
+
elif "audit trail" in observed:
|
| 3572 |
+
key = "audit trail"
|
| 3573 |
+
elif "external audit" in observed:
|
| 3574 |
+
key = "external audit"
|
| 3575 |
+
elif "informed consent" in observed:
|
| 3576 |
+
key = "informed consent"
|
| 3577 |
+
elif "ethic" in observed and "approval" in observed:
|
| 3578 |
+
key = "ethics approval"
|
| 3579 |
+
elif "review board" in observed or observed == "irb":
|
| 3580 |
+
key = "review board"
|
| 3581 |
+
elif "approval" in observed:
|
| 3582 |
+
key = "approval"
|
| 3583 |
+
elif "field journal" in observed:
|
| 3584 |
+
key = "field journal"
|
| 3585 |
+
elif "reflexive journal" in observed:
|
| 3586 |
+
key = "reflexive journal"
|
| 3587 |
+
elif "positionality" in observed:
|
| 3588 |
+
key = "positionality"
|
| 3589 |
+
else:
|
| 3590 |
+
key = "reflexive memo"
|
| 3591 |
+
authorized = any(key in item.casefold() for item in procedure_lists[category])
|
| 3592 |
+
if not authorized:
|
| 3593 |
+
replacements.append((start, end, ""))
|
| 3594 |
+
diagnostics.append({"type":"unsupported_study_procedure", "category":category, "procedure":key, "preview":core.strip()[:180]})
|
| 3595 |
+
removed = True
|
| 3596 |
+
break
|
|
|
|
| 3597 |
if removed or not canonical_relations:
|
| 3598 |
continue
|
| 3599 |
+
|
| 3600 |
+
# A pure participant-membership sentence can conflate two actor
|
| 3601 |
+
# groups under one source (e.g., "interview participants included
|
| 3602 |
+
# officers and farmers"). In that narrow case, render the frozen
|
| 3603 |
+
# relation set directly; this is safer than a phrase substitution
|
| 3604 |
+
# that would leave a grammatically or factually mixed sentence.
|
| 3605 |
+
participant_membership = bool(re.search(
|
| 3606 |
+
r"(?i)\b(?:interview|focus\s+group|observation)\s+participants?\b|"
|
| 3607 |
+
r"\bparticipants?\s+(?:included|comprised|consisted)\b", core
|
| 3608 |
+
))
|
| 3609 |
+
present_roles = []
|
| 3610 |
+
for role, source, count in canonical_relations:
|
| 3611 |
+
if any(re.search(rf"(?<![A-Za-z]){re.escape(variant)}(?![A-Za-z])", lower, flags=re.I)
|
| 3612 |
+
for variant in cls._qual_actor_variants(role)):
|
| 3613 |
+
present_roles.append((role, source, count))
|
| 3614 |
+
if participant_membership and len(present_roles) >= 2:
|
| 3615 |
+
clauses = [
|
| 3616 |
+
f"{source} with {role}" + (f" (n={count})" if isinstance(count, int) else "")
|
| 3617 |
+
for role, source, count in present_roles
|
| 3618 |
+
]
|
| 3619 |
+
canonical_sentence = "Data collection used " + " and ".join(clauses) + "."
|
| 3620 |
+
leading = core[: len(core) - len(core.lstrip())]
|
| 3621 |
+
trailing = core[len(core.rstrip()):]
|
| 3622 |
+
replacement = f"{leading}{canonical_sentence}{trailing}"
|
| 3623 |
+
if replacement != core:
|
| 3624 |
+
replacements.append((start, end, replacement))
|
| 3625 |
+
diagnostics.append({
|
| 3626 |
+
"type":"actor_data_source_relation_repaired",
|
| 3627 |
+
"actor":"multiple", "observed":"mixed_participant_source",
|
| 3628 |
+
"allowed":[cls._qual_source_family(source) for _role, source, _count in present_roles],
|
| 3629 |
+
})
|
| 3630 |
+
continue
|
| 3631 |
+
|
| 3632 |
+
# Repair only the mismatched source phrase nearest to the actor. This
|
| 3633 |
+
# preserves the sentence's substantive argument instead of replacing
|
| 3634 |
+
# the entire sentence with a mechanical data-collection template.
|
| 3635 |
for actor_variant, allowed_families in allowed_by_actor.items():
|
| 3636 |
+
actor_matches = list(re.finditer(rf"(?<![A-Za-z]){re.escape(actor_variant)}(?![A-Za-z])", lower, flags=re.I))
|
| 3637 |
+
if not actor_matches:
|
| 3638 |
+
continue
|
| 3639 |
+
changed_core = core
|
| 3640 |
+
actor_changed = False
|
| 3641 |
+
for actor_match in actor_matches:
|
| 3642 |
+
nearest = cls._nearest_qual_source_family(changed_core, actor_match.start())
|
| 3643 |
+
if not nearest or nearest in allowed_families:
|
| 3644 |
+
continue
|
| 3645 |
+
preferred_rows = canonical_by_actor.get(actor_variant) or []
|
| 3646 |
+
preferred = next((row for row in preferred_rows if row[2] in allowed_families), None)
|
| 3647 |
+
if not preferred:
|
| 3648 |
+
continue
|
| 3649 |
+
canonical_source = preferred[1]
|
| 3650 |
+
pattern = source_patterns.get(nearest)
|
| 3651 |
+
if not pattern:
|
| 3652 |
+
continue
|
| 3653 |
+
source_matches = list(re.finditer(pattern, changed_core, flags=re.I))
|
| 3654 |
+
if not source_matches:
|
| 3655 |
+
continue
|
| 3656 |
+
source_match = min(source_matches, key=lambda m: abs(m.start() - actor_match.start()))
|
| 3657 |
+
observed_phrase = source_match.group(0)
|
| 3658 |
+
replacement_source = canonical_source
|
| 3659 |
+
if re.fullmatch(r"(?i)interviewed", observed_phrase.strip()):
|
| 3660 |
+
replacement_source = f"conducted {canonical_source} with"
|
| 3661 |
+
elif re.fullmatch(r"(?i)observed", observed_phrase.strip()):
|
| 3662 |
+
replacement_source = f"used {canonical_source} with"
|
| 3663 |
+
candidate = changed_core[:source_match.start()] + replacement_source + changed_core[source_match.end():]
|
| 3664 |
+
if candidate != changed_core:
|
| 3665 |
+
diagnostics.append({
|
| 3666 |
+
"type":"actor_data_source_relation_repaired", "actor":actor_variant,
|
| 3667 |
+
"observed":nearest, "allowed":sorted(allowed_families),
|
| 3668 |
+
"replacement":canonical_source,
|
| 3669 |
+
})
|
| 3670 |
+
changed_core = candidate
|
| 3671 |
+
actor_changed = True
|
| 3672 |
+
break
|
| 3673 |
+
if actor_changed and changed_core != core:
|
| 3674 |
+
replacements.append((start, end, changed_core))
|
| 3675 |
break
|
| 3676 |
if replacements:
|
| 3677 |
for start, end, replacement in sorted(replacements, reverse=True):
|
core/quantitative_engine.py
CHANGED
|
@@ -17,7 +17,7 @@ from utils.file_parser import extract_data_from_files
|
|
| 17 |
class QuantitativeEngine:
|
| 18 |
"""Prepare one frozen quantitative evidence packet before manuscript writing."""
|
| 19 |
|
| 20 |
-
QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-
|
| 21 |
|
| 22 |
@staticmethod
|
| 23 |
def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
|
|
@@ -344,14 +344,70 @@ Keywords: {keywords}"""
|
|
| 344 |
spans.append((start, len(value), value[start:]))
|
| 345 |
return spans
|
| 346 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
@classmethod
|
| 348 |
def build_quantitative_evidence_ledger(cls, metadata, evidence_text=""):
|
| 349 |
"""Freeze only high-confidence study-owned quantitative facts.
|
| 350 |
|
| 351 |
System-generated evidence has typed metadata and therefore supports a
|
| 352 |
-
strict contract.
|
| 353 |
-
|
| 354 |
-
|
|
|
|
| 355 |
"""
|
| 356 |
meta = dict(metadata or {})
|
| 357 |
source_type = str(meta.get("source_type") or "")
|
|
@@ -359,6 +415,7 @@ Keywords: {keywords}"""
|
|
| 359 |
scale_points = meta.get("scale_points")
|
| 360 |
strict = source_type == "system_generated"
|
| 361 |
evidence = str(evidence_text or "")
|
|
|
|
| 362 |
if not strict and evidence:
|
| 363 |
sample_candidates = set()
|
| 364 |
for pattern in (
|
|
@@ -371,6 +428,39 @@ Keywords: {keywords}"""
|
|
| 371 |
scale_candidates = {int(x) for x in re.findall(r"(?i)\b([2-9]|1[0-1])[- ]point\s+Likert", evidence)}
|
| 372 |
if len(scale_candidates) == 1:
|
| 373 |
scale_points = next(iter(scale_candidates))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
constructs = dict(meta.get("constructs") or {}) if isinstance(meta.get("constructs"), dict) else {}
|
| 375 |
results = dict(meta.get("association_results") or {}) if isinstance(meta.get("association_results"), dict) else {}
|
| 376 |
return {
|
|
@@ -382,6 +472,7 @@ Keywords: {keywords}"""
|
|
| 382 |
"study_design": str(meta.get("study_design") or "").strip(),
|
| 383 |
"constructs": {k: str(v or "").strip() for k, v in constructs.items() if str(v or "").strip()},
|
| 384 |
"association_results": results,
|
|
|
|
| 385 |
"causal_identification": "none" if strict else "evidence_owned",
|
| 386 |
"allowed_methods": [
|
| 387 |
"reliability", "composite reliability", "average variance extracted",
|
|
@@ -398,7 +489,7 @@ Keywords: {keywords}"""
|
|
| 398 |
|
| 399 |
@classmethod
|
| 400 |
def repair_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 401 |
-
"""Repair
|
| 402 |
value = str(text or "")
|
| 403 |
meta = dict(metadata or {})
|
| 404 |
ledger = dict(meta.get("quantitative_evidence_ledger") or {})
|
|
@@ -410,20 +501,34 @@ Keywords: {keywords}"""
|
|
| 410 |
strict = bool(ledger.get("strict"))
|
| 411 |
constructs = dict(ledger.get("constructs") or {})
|
| 412 |
results = dict(ledger.get("association_results") or {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
for start, end, sentence in spans:
|
| 419 |
-
has_citation = bool(re.search(r"\([^()]*?(?:19|20)\d{2}[a-z]?[^()]*?\)", sentence))
|
| 420 |
-
study_owned = bool(re.search(
|
| 421 |
-
r"(?i)\b(?:this\s+study|our\s+study|we|our\s+sample|the\s+sample|sample\s+(?:comprised|consisted|included)|respondents?|participants?)\b",
|
| 422 |
-
sentence,
|
| 423 |
-
))
|
| 424 |
-
if has_citation or not study_owned:
|
| 425 |
continue
|
| 426 |
revised = sentence
|
|
|
|
| 427 |
if sample_size:
|
| 428 |
sample_pattern = re.compile(r"(?i)(\b(?:sample(?:\s+size)?(?:\s+of)?|N)\s*(?:=|:)?\s*)(\d{2,6})\b")
|
| 429 |
def sample_repl(match):
|
|
@@ -450,79 +555,36 @@ Keywords: {keywords}"""
|
|
| 450 |
diagnostics.append({"type":"scale_points_repaired","observed":observed,"expected":int(scale_points)})
|
| 451 |
return f"{int(scale_points)}-point {match.group(2)}"
|
| 452 |
revised = scale_pattern.sub(scale_repl, revised)
|
| 453 |
-
if revised != sentence:
|
| 454 |
-
sample_scale_replacements.append((start, end, revised))
|
| 455 |
-
for start, end, replacement in sorted(sample_scale_replacements, reverse=True):
|
| 456 |
-
value = value[:start] + replacement + value[end:]
|
| 457 |
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
"mediator": r"(?:mediator|mediating\s+variable|mediating\s+construct)",
|
| 462 |
-
"outcome": r"(?:outcome|dependent\s+variable|outcome\s+construct)",
|
| 463 |
-
}
|
| 464 |
-
known_constructs = [str(v) for v in constructs.values() if str(v)]
|
| 465 |
-
for role, role_pattern in role_patterns.items():
|
| 466 |
-
expected = str(constructs.get(role) or "")
|
| 467 |
-
if not expected:
|
| 468 |
-
continue
|
| 469 |
-
for candidate in known_constructs:
|
| 470 |
-
if candidate.casefold() == expected.casefold():
|
| 471 |
continue
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
|
|
|
|
|
|
| 475 |
if count:
|
| 476 |
diagnostics.append({"type":"construct_role_repaired","role":role,"observed":candidate,"expected":expected,"count":count})
|
| 477 |
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
(
|
| 484 |
-
(r"(?i)\ban\s+causal\s+effect\b", "an association"),
|
| 485 |
-
(r"(?i)\ba\s+causal\s+effect\b", "an association"),
|
| 486 |
-
(r"(?i)\bthe\s+causal\s+effect\b", "the association"),
|
| 487 |
-
(r"(?i)\bcausal\s+effects\b", "associations"),
|
| 488 |
-
(r"(?i)\bcausal\s+effect\b", "association"),
|
| 489 |
-
(r"(?i)\bcausal\s+mediation\b", "indirect association"),
|
| 490 |
-
)
|
| 491 |
-
for pattern, replacement in replacements:
|
| 492 |
-
value, count = re.subn(pattern, replacement, value)
|
| 493 |
-
if count:
|
| 494 |
-
diagnostics.append({"type":"causal_language_repaired","replacement":replacement,"count":count})
|
| 495 |
-
|
| 496 |
-
# Remove p-values attached to this study's frozen constructs because
|
| 497 |
-
# no p-values are generated/frozen by this engine. External cited
|
| 498 |
-
# literature claims are left untouched.
|
| 499 |
-
construct_folded = [c.casefold() for c in known_constructs]
|
| 500 |
-
spans = cls._sentence_spans(value)
|
| 501 |
-
replacements_by_span = []
|
| 502 |
-
forbidden = [str(x) for x in ledger.get("forbidden_unowned_methods") or []]
|
| 503 |
-
section_sensitive = any(token in str(section_name or "").casefold() for token in ("method", "result", "abstract", "final", "analysis"))
|
| 504 |
-
for start, end, sentence in spans:
|
| 505 |
-
lower = sentence.casefold()
|
| 506 |
-
has_construct = any(c in lower for c in construct_folded) if construct_folded else False
|
| 507 |
-
has_citation = bool(re.search(r"\([^()]*?(?:19|20)\d{2}[a-z]?[^()]*?\)", sentence))
|
| 508 |
-
revised = sentence
|
| 509 |
-
if has_construct and not has_citation:
|
| 510 |
revised, p_count = re.subn(r"(?i)\s*[,;]?\s*p\s*(?:=|<|>|≤|≥)\s*(?:0?\.\d+|\d+(?:\.\d+)?)", "", revised)
|
| 511 |
if p_count:
|
| 512 |
diagnostics.append({"type":"unfrozen_p_value_removed","count":p_count})
|
| 513 |
-
if
|
| 514 |
-
bad = next((term for term in forbidden if re.search(rf"(?i)(?<![A-Za-z]){re.escape(term)}(?![A-Za-z])",
|
| 515 |
if bad:
|
| 516 |
-
revised = ""
|
| 517 |
diagnostics.append({"type":"unsupported_quantitative_method_removed","method":bad,"preview":sentence.strip()[:180]})
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
# High-confidence frozen numeric anchors.
|
| 524 |
-
def replace_metric(pattern, expected, dtype="float", label="metric"):
|
| 525 |
-
nonlocal value
|
| 526 |
if expected is None:
|
| 527 |
return
|
| 528 |
expected_text = str(int(expected)) if dtype == "int" else f"{float(expected):.3f}".rstrip("0").rstrip(".")
|
|
@@ -537,61 +599,56 @@ Keywords: {keywords}"""
|
|
| 537 |
return match.group(0)
|
| 538 |
diagnostics.append({"type":"quantitative_metric_repaired","metric":label,"observed":observed,"expected":expected_text})
|
| 539 |
return match.group(1) + expected_text
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
|
| 542 |
-
if results:
|
| 543 |
-
replace_metric(r"(\bVIF\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", results.get("predictor_mediator_vif"), label="VIF")
|
| 544 |
-
replace_metric(r"(\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*)(\d{2,6})", results.get("bootstrap_iterations"), dtype="int", label="bootstrap_iterations")
|
| 545 |
for role, key in (("mediator", "r2_mediator"), ("outcome", "r2_outcome")):
|
| 546 |
construct = str(constructs.get(role) or "")
|
| 547 |
if construct and results.get(key) is not None:
|
| 548 |
metric_pattern = rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)"
|
| 549 |
-
|
| 550 |
|
| 551 |
-
|
|
|
|
|
|
|
| 552 |
("indirect", results.get("indirect"), [constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")]),
|
| 553 |
("direct", results.get("direct"), [constructs.get("predictor"), constructs.get("outcome")]),
|
| 554 |
("a", results.get("a"), [constructs.get("predictor"), constructs.get("mediator")]),
|
| 555 |
("b", results.get("b"), [constructs.get("mediator"), constructs.get("outcome")]),
|
| 556 |
]
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
):
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
selected = association_specs[3]
|
| 580 |
-
if selected and selected[1] is not None:
|
| 581 |
-
expected = float(selected[1])
|
| 582 |
-
def beta_repl(match):
|
| 583 |
-
observed = float(match.group(2))
|
| 584 |
-
if abs(observed - expected) < 0.0006:
|
| 585 |
-
return match.group(0)
|
| 586 |
-
expected_text = f"{expected:.3f}".rstrip("0").rstrip(".")
|
| 587 |
-
diagnostics.append({"type":"quantitative_metric_repaired","metric":selected[0],"observed":match.group(2),"expected":expected_text})
|
| 588 |
-
return match.group(1) + expected_text
|
| 589 |
-
revised = re.sub(r"(?i)((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", beta_repl, sentence)
|
| 590 |
-
if revised != sentence:
|
| 591 |
-
metric_spans.append((start, end, revised))
|
| 592 |
-
for start, end, replacement in sorted(metric_spans, reverse=True):
|
| 593 |
-
value = value[:start] + replacement + value[end:]
|
| 594 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
value = re.sub(r"[ \t]+\n", "\n", value)
|
| 596 |
value = re.sub(r"\n{3,}", "\n\n", value)
|
| 597 |
value = re.sub(r" {2,}", " ", value)
|
|
|
|
| 17 |
class QuantitativeEngine:
|
| 18 |
"""Prepare one frozen quantitative evidence packet before manuscript writing."""
|
| 19 |
|
| 20 |
+
QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-v2"
|
| 21 |
|
| 22 |
@staticmethod
|
| 23 |
def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
|
|
|
|
| 344 |
spans.append((start, len(value), value[start:]))
|
| 345 |
return spans
|
| 346 |
|
| 347 |
+
@staticmethod
|
| 348 |
+
def _has_external_literature_marker(sentence):
|
| 349 |
+
value = str(sentence or "")
|
| 350 |
+
if re.search(r"\bCITE-\d+\b", value, flags=re.I):
|
| 351 |
+
return True
|
| 352 |
+
if re.search(
|
| 353 |
+
r"\((?=[^()\n]*[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+(?:\s+(?:&\s+[A-Za-zÀ-ÖØ-öø-ÿ'’.-]+|et\s+al\.))?\s*,\s*(?:19|20)\d{2}[a-z]?\b)[^()\n]+\)",
|
| 354 |
+
value, flags=re.I,
|
| 355 |
+
):
|
| 356 |
+
return True
|
| 357 |
+
if re.search(r"\b[A-Z][A-Za-z'’.-]+\s+et\s+al\.\s*\((?:19|20)\d{2}[a-z]?\)", value):
|
| 358 |
+
return True
|
| 359 |
+
return False
|
| 360 |
+
|
| 361 |
+
@classmethod
|
| 362 |
+
def _quantitative_sentence_ownership(cls, sentence, section_name="", known_constructs=None):
|
| 363 |
+
"""Return external/study/result/unknown for deterministic AMJ repair.
|
| 364 |
+
|
| 365 |
+
Only study/result-owned prose may be rewritten. External literature is
|
| 366 |
+
immutable even when it reports the same constructs or statistics.
|
| 367 |
+
"""
|
| 368 |
+
value = str(sentence or "")
|
| 369 |
+
stripped = value.strip()
|
| 370 |
+
if not stripped or stripped.startswith(">"):
|
| 371 |
+
return "external" if stripped else "unknown"
|
| 372 |
+
if cls._has_external_literature_marker(value):
|
| 373 |
+
return "external"
|
| 374 |
+
if re.search(r"(?i)\b(?:prior|previous|earlier|existing)\s+(?:research|stud(?:y|ies)|literature)\b", value):
|
| 375 |
+
return "external"
|
| 376 |
+
if re.search(r"(?i)\b(?:we|our\s+study|this\s+study|the\s+present\s+study|our\s+sample|our\s+analysis|our\s+results|the\s+sample|the\s+survey|respondents?|participants?)\b", value):
|
| 377 |
+
return "study"
|
| 378 |
+
section = str(section_name or "").casefold()
|
| 379 |
+
constructs = [str(x or "").casefold() for x in (known_constructs or []) if str(x or "").strip()]
|
| 380 |
+
has_construct = any(item in value.casefold() for item in constructs)
|
| 381 |
+
has_metric = bool(re.search(r"(?i)(?:\bVIF\b|\bbootstrap\b|R(?:²|\^2)|(?:β|standardized\s+estimate)\s*(?:=|:)|\bp\s*(?:=|<|>|≤|≥))", value))
|
| 382 |
+
has_role = bool(re.search(r"(?i)\b(?:predictor|mediator|outcome|independent\s+variable|dependent\s+variable)\b", value))
|
| 383 |
+
if any(token in section for token in ("result", "analysis", "abstract", "final")) and (has_construct or has_metric or has_role):
|
| 384 |
+
return "result"
|
| 385 |
+
if any(token in section for token in ("method", "measure")) and (has_construct or has_role or re.search(r"(?i)\b(?:Likert|sample|survey)\b", value)):
|
| 386 |
+
return "study"
|
| 387 |
+
return "unknown"
|
| 388 |
+
|
| 389 |
+
@staticmethod
|
| 390 |
+
def _unique_numeric(pattern, evidence, *, integer=False):
|
| 391 |
+
values = []
|
| 392 |
+
for raw in re.findall(pattern, str(evidence or ""), flags=re.I):
|
| 393 |
+
raw_value = raw[-1] if isinstance(raw, tuple) else raw
|
| 394 |
+
try:
|
| 395 |
+
value = int(float(raw_value)) if integer else float(raw_value)
|
| 396 |
+
except Exception:
|
| 397 |
+
continue
|
| 398 |
+
values.append(value)
|
| 399 |
+
unique = sorted(set(values))
|
| 400 |
+
return unique[0] if len(unique) == 1 else None
|
| 401 |
+
|
| 402 |
@classmethod
|
| 403 |
def build_quantitative_evidence_ledger(cls, metadata, evidence_text=""):
|
| 404 |
"""Freeze only high-confidence study-owned quantitative facts.
|
| 405 |
|
| 406 |
System-generated evidence has typed metadata and therefore supports a
|
| 407 |
+
strict contract. User-provided evidence is parsed conservatively: only
|
| 408 |
+
uniquely labelled values are promoted to deterministic authority.
|
| 409 |
+
Ambiguous values remain owned by the uploaded evidence text and are not
|
| 410 |
+
guessed or globally substituted.
|
| 411 |
"""
|
| 412 |
meta = dict(metadata or {})
|
| 413 |
source_type = str(meta.get("source_type") or "")
|
|
|
|
| 415 |
scale_points = meta.get("scale_points")
|
| 416 |
strict = source_type == "system_generated"
|
| 417 |
evidence = str(evidence_text or "")
|
| 418 |
+
uploaded_metric_anchors = {}
|
| 419 |
if not strict and evidence:
|
| 420 |
sample_candidates = set()
|
| 421 |
for pattern in (
|
|
|
|
| 428 |
scale_candidates = {int(x) for x in re.findall(r"(?i)\b([2-9]|1[0-1])[- ]point\s+Likert", evidence)}
|
| 429 |
if len(scale_candidates) == 1:
|
| 430 |
scale_points = next(iter(scale_candidates))
|
| 431 |
+
|
| 432 |
+
vif = cls._unique_numeric(r"(?i)\bVIF\s*(?:=|:)?\s*([-+]?\d+(?:\.\d+)?)", evidence)
|
| 433 |
+
bootstrap = cls._unique_numeric(r"(?i)\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*(\d{2,6})", evidence, integer=True)
|
| 434 |
+
if vif is not None:
|
| 435 |
+
uploaded_metric_anchors["VIF"] = vif
|
| 436 |
+
if bootstrap is not None:
|
| 437 |
+
uploaded_metric_anchors["bootstrap_iterations"] = int(bootstrap)
|
| 438 |
+
|
| 439 |
+
r2_by_label = {}
|
| 440 |
+
for match in re.finditer(
|
| 441 |
+
r"(?i)R(?:²|\^2)\s*(?:for|of)\s*([A-Za-z][A-Za-z0-9 _\-/]{1,80}?)\s*(?:=|:)\s*([-+]?\d+(?:\.\d+)?)",
|
| 442 |
+
evidence,
|
| 443 |
+
):
|
| 444 |
+
label = re.sub(r"\s+", " ", match.group(1)).strip().casefold()
|
| 445 |
+
r2_by_label.setdefault(label, set()).add(float(match.group(2)))
|
| 446 |
+
uploaded_metric_anchors["r2_by_label"] = {
|
| 447 |
+
label: next(iter(values)) for label, values in r2_by_label.items() if len(values) == 1
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
beta_by_path = {}
|
| 451 |
+
for match in re.finditer(
|
| 452 |
+
r"(?i)([A-Za-z][A-Za-z0-9 _\-/]{1,60}?)\s*(?:→|->)\s*([A-Za-z][A-Za-z0-9 _\-/]{1,60}?)\s*[,;:]?\s*(?:β|standardized\s+estimate)\s*(?:=|:)\s*([-+]?\d+(?:\.\d+)?)",
|
| 453 |
+
evidence,
|
| 454 |
+
):
|
| 455 |
+
key = (
|
| 456 |
+
re.sub(r"\s+", " ", match.group(1)).strip().casefold(),
|
| 457 |
+
re.sub(r"\s+", " ", match.group(2)).strip().casefold(),
|
| 458 |
+
)
|
| 459 |
+
beta_by_path.setdefault(key, set()).add(float(match.group(3)))
|
| 460 |
+
uploaded_metric_anchors["beta_by_path"] = {
|
| 461 |
+
" -> ".join(key): next(iter(values)) for key, values in beta_by_path.items() if len(values) == 1
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
constructs = dict(meta.get("constructs") or {}) if isinstance(meta.get("constructs"), dict) else {}
|
| 465 |
results = dict(meta.get("association_results") or {}) if isinstance(meta.get("association_results"), dict) else {}
|
| 466 |
return {
|
|
|
|
| 472 |
"study_design": str(meta.get("study_design") or "").strip(),
|
| 473 |
"constructs": {k: str(v or "").strip() for k, v in constructs.items() if str(v or "").strip()},
|
| 474 |
"association_results": results,
|
| 475 |
+
"uploaded_metric_anchors": uploaded_metric_anchors,
|
| 476 |
"causal_identification": "none" if strict else "evidence_owned",
|
| 477 |
"allowed_methods": [
|
| 478 |
"reliability", "composite reliability", "average variance extracted",
|
|
|
|
| 489 |
|
| 490 |
@classmethod
|
| 491 |
def repair_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 492 |
+
"""Repair deterministic AMJ drift only in study/result-owned prose."""
|
| 493 |
value = str(text or "")
|
| 494 |
meta = dict(metadata or {})
|
| 495 |
ledger = dict(meta.get("quantitative_evidence_ledger") or {})
|
|
|
|
| 501 |
strict = bool(ledger.get("strict"))
|
| 502 |
constructs = dict(ledger.get("constructs") or {})
|
| 503 |
results = dict(ledger.get("association_results") or {})
|
| 504 |
+
uploaded = dict(ledger.get("uploaded_metric_anchors") or {})
|
| 505 |
+
known_constructs = [str(v) for v in constructs.values() if str(v)]
|
| 506 |
+
construct_folded = [c.casefold() for c in known_constructs]
|
| 507 |
+
forbidden = [str(x) for x in ledger.get("forbidden_unowned_methods") or []]
|
| 508 |
+
role_patterns = {
|
| 509 |
+
"predictor": r"(?:predictor|independent\s+variable|predictor\s+construct)",
|
| 510 |
+
"mediator": r"(?:mediator|mediating\s+variable|mediating\s+construct)",
|
| 511 |
+
"outcome": r"(?:outcome|dependent\s+variable|outcome\s+construct)",
|
| 512 |
+
}
|
| 513 |
+
causal_replacements = (
|
| 514 |
+
(r"(?i)\bhad\s+a\s+causal\s+effect\s+on\b", "was associated with"),
|
| 515 |
+
(r"(?i)\bexerted\s+a\s+causal\s+effect\s+on\b", "was associated with"),
|
| 516 |
+
(r"(?i)\bcausal\s+effect\s+on\b", "association with"),
|
| 517 |
+
(r"(?i)\ban\s+causal\s+effect\b", "an association"),
|
| 518 |
+
(r"(?i)\ba\s+causal\s+effect\b", "an association"),
|
| 519 |
+
(r"(?i)\bthe\s+causal\s+effect\b", "the association"),
|
| 520 |
+
(r"(?i)\bcausal\s+effects\b", "associations"),
|
| 521 |
+
(r"(?i)\bcausal\s+effect\b", "association"),
|
| 522 |
+
(r"(?i)\bcausal\s+mediation\b", "indirect association"),
|
| 523 |
+
)
|
| 524 |
|
| 525 |
+
replacements = []
|
| 526 |
+
for start, end, sentence in cls._sentence_spans(value):
|
| 527 |
+
ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
|
| 528 |
+
if ownership not in {"study", "result"}:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
continue
|
| 530 |
revised = sentence
|
| 531 |
+
|
| 532 |
if sample_size:
|
| 533 |
sample_pattern = re.compile(r"(?i)(\b(?:sample(?:\s+size)?(?:\s+of)?|N)\s*(?:=|:)?\s*)(\d{2,6})\b")
|
| 534 |
def sample_repl(match):
|
|
|
|
| 555 |
diagnostics.append({"type":"scale_points_repaired","observed":observed,"expected":int(scale_points)})
|
| 556 |
return f"{int(scale_points)}-point {match.group(2)}"
|
| 557 |
revised = scale_pattern.sub(scale_repl, revised)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
|
| 559 |
+
for role, role_pattern in role_patterns.items():
|
| 560 |
+
expected = str(constructs.get(role) or "")
|
| 561 |
+
if not expected:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
continue
|
| 563 |
+
for candidate in known_constructs:
|
| 564 |
+
if candidate.casefold() == expected.casefold():
|
| 565 |
+
continue
|
| 566 |
+
pattern = re.compile(rf"(?i)({role_pattern}\s*(?:was|is|:|=)?\s*){re.escape(candidate)}")
|
| 567 |
+
revised, count = pattern.subn(lambda m: m.group(1) + expected, revised)
|
| 568 |
if count:
|
| 569 |
diagnostics.append({"type":"construct_role_repaired","role":role,"observed":candidate,"expected":expected,"count":count})
|
| 570 |
|
| 571 |
+
if strict:
|
| 572 |
+
for pattern, replacement in causal_replacements:
|
| 573 |
+
revised, count = re.subn(pattern, replacement, revised)
|
| 574 |
+
if count:
|
| 575 |
+
diagnostics.append({"type":"causal_language_repaired","replacement":replacement,"count":count})
|
| 576 |
+
if any(c in revised.casefold() for c in construct_folded) if construct_folded else False:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
revised, p_count = re.subn(r"(?i)\s*[,;]?\s*p\s*(?:=|<|>|≤|≥)\s*(?:0?\.\d+|\d+(?:\.\d+)?)", "", revised)
|
| 578 |
if p_count:
|
| 579 |
diagnostics.append({"type":"unfrozen_p_value_removed","count":p_count})
|
| 580 |
+
if ownership == "study":
|
| 581 |
+
bad = next((term for term in forbidden if re.search(rf"(?i)(?<![A-Za-z]){re.escape(term)}(?![A-Za-z])", revised)), "")
|
| 582 |
if bad:
|
|
|
|
| 583 |
diagnostics.append({"type":"unsupported_quantitative_method_removed","method":bad,"preview":sentence.strip()[:180]})
|
| 584 |
+
revised = ""
|
| 585 |
+
|
| 586 |
+
def replace_local_metric(pattern, expected, *, dtype="float", label="metric"):
|
| 587 |
+
nonlocal revised
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
if expected is None:
|
| 589 |
return
|
| 590 |
expected_text = str(int(expected)) if dtype == "int" else f"{float(expected):.3f}".rstrip("0").rstrip(".")
|
|
|
|
| 599 |
return match.group(0)
|
| 600 |
diagnostics.append({"type":"quantitative_metric_repaired","metric":label,"observed":observed,"expected":expected_text})
|
| 601 |
return match.group(1) + expected_text
|
| 602 |
+
revised = regex.sub(repl, revised)
|
| 603 |
+
|
| 604 |
+
vif_expected = results.get("predictor_mediator_vif") if strict else uploaded.get("VIF")
|
| 605 |
+
boot_expected = results.get("bootstrap_iterations") if strict else uploaded.get("bootstrap_iterations")
|
| 606 |
+
replace_local_metric(r"(\bVIF\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", vif_expected, label="VIF")
|
| 607 |
+
replace_local_metric(r"(\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*)(\d{2,6})", boot_expected, dtype="int", label="bootstrap_iterations")
|
| 608 |
|
| 609 |
+
if strict and results:
|
|
|
|
|
|
|
| 610 |
for role, key in (("mediator", "r2_mediator"), ("outcome", "r2_outcome")):
|
| 611 |
construct = str(constructs.get(role) or "")
|
| 612 |
if construct and results.get(key) is not None:
|
| 613 |
metric_pattern = rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)"
|
| 614 |
+
replace_local_metric(metric_pattern, results.get(key), label=key)
|
| 615 |
|
| 616 |
+
lower = revised.casefold()
|
| 617 |
+
selected = None
|
| 618 |
+
specs = [
|
| 619 |
("indirect", results.get("indirect"), [constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")]),
|
| 620 |
("direct", results.get("direct"), [constructs.get("predictor"), constructs.get("outcome")]),
|
| 621 |
("a", results.get("a"), [constructs.get("predictor"), constructs.get("mediator")]),
|
| 622 |
("b", results.get("b"), [constructs.get("mediator"), constructs.get("outcome")]),
|
| 623 |
]
|
| 624 |
+
if "indirect" in lower and all(str(x or "").casefold() in lower for x in specs[0][2] if x):
|
| 625 |
+
selected = specs[0]
|
| 626 |
+
elif f"controlling {str(constructs.get('mediator') or '').casefold()}" in lower and str(constructs.get("predictor") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower:
|
| 627 |
+
selected = specs[1]
|
| 628 |
+
elif f"controlling {str(constructs.get('predictor') or '').casefold()}" in lower and str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower:
|
| 629 |
+
selected = specs[3]
|
| 630 |
+
elif str(constructs.get("predictor") or "").casefold() in lower and str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() not in lower:
|
| 631 |
+
selected = specs[2]
|
| 632 |
+
elif str(constructs.get("mediator") or "").casefold() in lower and str(constructs.get("outcome") or "").casefold() in lower and str(constructs.get("predictor") or "").casefold() not in lower:
|
| 633 |
+
selected = specs[3]
|
| 634 |
+
if selected and selected[1] is not None:
|
| 635 |
+
replace_local_metric(r"((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", selected[1], label=selected[0])
|
| 636 |
+
elif not strict:
|
| 637 |
+
r2_map = dict(uploaded.get("r2_by_label") or {})
|
| 638 |
+
for label, expected in r2_map.items():
|
| 639 |
+
if label and label in revised.casefold():
|
| 640 |
+
replace_local_metric(rf"(R(?:²|\^2)\s*(?:for|of)\s*{re.escape(label)}\s*(?:=|:)\s*)([-+]?\d+(?:\.\d+)?)", expected, label=f"uploaded_R2:{label}")
|
| 641 |
+
beta_map = dict(uploaded.get("beta_by_path") or {})
|
| 642 |
+
for path, expected in beta_map.items():
|
| 643 |
+
left, _, right = path.partition(" -> ")
|
| 644 |
+
if left and right and left in revised.casefold() and right in revised.casefold():
|
| 645 |
+
replace_local_metric(r"((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", expected, label=f"uploaded_beta:{path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
|
| 647 |
+
if revised != sentence:
|
| 648 |
+
replacements.append((start, end, revised))
|
| 649 |
+
|
| 650 |
+
for start, end, replacement in sorted(replacements, reverse=True):
|
| 651 |
+
value = value[:start] + replacement + value[end:]
|
| 652 |
value = re.sub(r"[ \t]+\n", "\n", value)
|
| 653 |
value = re.sub(r"\n{3,}", "\n\n", value)
|
| 654 |
value = re.sub(r" {2,}", " ", value)
|