Spaces:
Running
Running
P0-R1.1-R2-S0-R1 Deployment Source Correction
#73
by GuXiaopeng - opened
- core/generation_runner.py +523 -218
- core/pipeline_agent.py +250 -179
- core/quantitative_engine.py +140 -4
core/generation_runner.py
CHANGED
|
@@ -7,6 +7,7 @@ connections. Checkpoints are task-scoped, immutable-by-sequence JSON snapshots.
|
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
import hashlib
|
| 11 |
import inspect
|
| 12 |
import json
|
|
@@ -912,24 +913,121 @@ class GenerationRunner:
|
|
| 912 |
def _repair_validate_study_fact_contract(
|
| 913 |
mode: str, agent, quant_engine, text: str, data_metadata: dict, *, section_name: str
|
| 914 |
):
|
|
|
|
| 915 |
value = str(text or '').strip()
|
| 916 |
diagnostics = []
|
| 917 |
if str(mode or '') == 'asq':
|
| 918 |
-
|
| 919 |
-
value, data_metadata, section_name=section_name
|
| 920 |
-
)
|
| 921 |
-
agent.validate_qualitative_study_fact_contract(
|
| 922 |
value, data_metadata, section_name=section_name
|
| 923 |
)
|
| 924 |
elif str(mode or '') == 'amj':
|
| 925 |
-
|
| 926 |
-
value, data_metadata, section_name=section_name
|
| 927 |
-
)
|
| 928 |
-
quant_engine.validate_quantitative_study_fact_contract(
|
| 929 |
value, data_metadata, section_name=section_name
|
| 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()
|
|
@@ -957,6 +1055,16 @@ class GenerationRunner:
|
|
| 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 {})
|
|
@@ -1862,13 +1970,14 @@ class GenerationRunner:
|
|
| 1862 |
old_tokens = cls._paragraph_token_set(old)
|
| 1863 |
union = tokens | old_tokens
|
| 1864 |
similarity = len(tokens & old_tokens) / max(1, len(union))
|
| 1865 |
-
|
|
|
|
| 1866 |
continue
|
| 1867 |
old_citations = set(re.findall(r'\[CITE-\d+\]', old, flags=re.I))
|
| 1868 |
old_numerics = set(re.findall(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?(?![A-Za-z])', old))
|
| 1869 |
# Never delete a paragraph carrying different evidence/facts.
|
| 1870 |
if citations == old_citations and numerics == old_numerics:
|
| 1871 |
-
duplicate = (
|
| 1872 |
break
|
| 1873 |
if duplicate:
|
| 1874 |
diagnostics.append({"similarity": round(duplicate[0], 4), "removed_preview": value[:180]})
|
|
@@ -2113,23 +2222,33 @@ class GenerationRunner:
|
|
| 2113 |
working_keywords = list(working_front.get("keywords") or [])
|
| 2114 |
if not working_abstract or not working_keywords:
|
| 2115 |
raise RuntimeError("working_front_matter_missing")
|
| 2116 |
-
|
| 2117 |
mode, agent, quant_engine, working_abstract, data_metadata,
|
| 2118 |
section_name="Working Abstract",
|
| 2119 |
)
|
| 2120 |
-
if not working_abstract:
|
| 2121 |
-
raise RuntimeError("working_front_matter_empty_after_study_fact_contract")
|
| 2122 |
if front_fact_rows:
|
|
|
|
| 2123 |
logging.warning(
|
| 2124 |
-
"Working Abstract
|
| 2125 |
-
mode, len(front_fact_rows),
|
| 2126 |
-
sorted({str(row.get("type") or "") for row in front_fact_rows}),
|
| 2127 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2128 |
quality_diagnostics.append({
|
| 2129 |
"scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
|
| 2130 |
-
"status":"
|
| 2131 |
-
"examples":front_fact_rows[:3],
|
| 2132 |
})
|
|
|
|
|
|
|
| 2133 |
agent.global_memory["working_abstract"] = working_abstract
|
| 2134 |
|
| 2135 |
self._progress(14, "outline_planning")
|
|
@@ -2359,36 +2478,48 @@ class GenerationRunner:
|
|
| 2359 |
working_abstract = str(state.get("working_abstract") or "").strip()
|
| 2360 |
working_keywords = list(state.get("working_keywords") or [])
|
| 2361 |
if working_abstract and not state.get("english_finalized"):
|
| 2362 |
-
|
| 2363 |
-
mode, agent, quant_engine, working_abstract, data_metadata,
|
| 2364 |
-
|
| 2365 |
-
|
| 2366 |
-
|
| 2367 |
-
|
| 2368 |
-
|
| 2369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2370 |
state["working_abstract"] = working_abstract
|
|
|
|
| 2371 |
if str(state.get("full_draft_en") or "").strip():
|
| 2372 |
state["full_draft_en"] = replace_abstract(str(state.get("full_draft_en") or ""), working_abstract)
|
|
|
|
| 2373 |
full_draft_en = str(state["full_draft_en"] or "")
|
| 2374 |
quality_diagnostics.append({
|
| 2375 |
"scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
|
| 2376 |
-
"status":"
|
| 2377 |
-
"examples":resumed_front_rows[:3],
|
| 2378 |
})
|
| 2379 |
state["quality_diagnostics"] = quality_diagnostics
|
| 2380 |
-
self._checkpoint("
|
| 2381 |
if working_abstract:
|
| 2382 |
agent.global_memory["working_abstract"] = working_abstract
|
| 2383 |
if self._preview_target_text == '':
|
| 2384 |
self._preview_target_text = str(state.get('full_draft_translated') or '')
|
| 2385 |
|
| 2386 |
-
#
|
| 2387 |
-
#
|
| 2388 |
-
#
|
| 2389 |
-
#
|
| 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
|
|
@@ -2401,32 +2532,55 @@ class GenerationRunner:
|
|
| 2401 |
and existing_seal.get("sha256") == self._section_integrity_hash(section_text)
|
| 2402 |
):
|
| 2403 |
continue
|
| 2404 |
-
|
| 2405 |
-
|
| 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 |
-
|
| 2412 |
-
|
| 2413 |
-
|
| 2414 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2415 |
translation_sections.pop(section_key, None)
|
| 2416 |
-
|
| 2417 |
quality_diagnostics.append({
|
| 2418 |
"scope":"english_section_seal_migration", "name":section_title,
|
| 2419 |
-
"status":"
|
| 2420 |
-
"examples":
|
| 2421 |
})
|
| 2422 |
-
|
| 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
|
| 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 = ""
|
|
@@ -2440,6 +2594,7 @@ class GenerationRunner:
|
|
| 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,
|
|
@@ -2519,23 +2674,28 @@ class GenerationRunner:
|
|
| 2519 |
working_keywords = list(working_front.get("keywords") or [])
|
| 2520 |
if not working_abstract or not working_keywords:
|
| 2521 |
raise RuntimeError("working_front_matter_missing")
|
| 2522 |
-
|
| 2523 |
-
mode, agent, quant_engine, working_abstract, data_metadata,
|
| 2524 |
-
section_name="Working Abstract",
|
| 2525 |
)
|
| 2526 |
-
if not working_abstract:
|
| 2527 |
-
raise RuntimeError("working_front_matter_empty_after_study_fact_contract")
|
| 2528 |
if front_fact_rows:
|
| 2529 |
-
|
| 2530 |
-
|
| 2531 |
-
|
| 2532 |
-
|
| 2533 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2534 |
quality_diagnostics.append({
|
| 2535 |
"scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
|
| 2536 |
-
"status":"
|
| 2537 |
-
"examples":front_fact_rows[:3],
|
| 2538 |
})
|
|
|
|
|
|
|
| 2539 |
agent.global_memory["working_abstract"] = working_abstract
|
| 2540 |
full_draft_en = replace_abstract(full_draft_en, working_abstract)
|
| 2541 |
full_draft_en = self._replace_keywords(full_draft_en, working_keywords)
|
|
@@ -2817,175 +2977,291 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2817 |
prefix += f"# {sec_title}\n\n"
|
| 2818 |
if sec.get("subsection_first") and subsection:
|
| 2819 |
prefix += f"## {subsection}\n\n"
|
| 2820 |
-
|
| 2821 |
-
|
| 2822 |
-
|
| 2823 |
-
|
| 2824 |
-
|
| 2825 |
-
|
| 2826 |
-
|
| 2827 |
-
|
| 2828 |
-
|
| 2829 |
-
|
| 2830 |
-
|
| 2831 |
-
|
| 2832 |
-
|
| 2833 |
-
|
| 2834 |
-
|
| 2835 |
-
|
| 2836 |
-
|
| 2837 |
-
|
| 2838 |
-
|
| 2839 |
-
|
| 2840 |
-
|
| 2841 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2842 |
evidence_contract=evidence_packet,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2843 |
)
|
| 2844 |
-
|
| 2845 |
-
|
| 2846 |
-
|
| 2847 |
-
|
| 2848 |
-
|
| 2849 |
-
|
| 2850 |
-
|
| 2851 |
-
|
| 2852 |
-
|
| 2853 |
-
|
| 2854 |
-
|
| 2855 |
-
|
| 2856 |
-
|
| 2857 |
-
|
| 2858 |
-
|
| 2859 |
-
|
| 2860 |
-
|
| 2861 |
-
|
| 2862 |
-
|
| 2863 |
-
|
| 2864 |
-
|
| 2865 |
-
|
| 2866 |
-
|
| 2867 |
-
|
| 2868 |
-
|
| 2869 |
-
|
| 2870 |
-
|
| 2871 |
-
|
| 2872 |
-
|
| 2873 |
-
|
| 2874 |
-
|
| 2875 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2876 |
)
|
| 2877 |
-
if len(
|
| 2878 |
-
|
| 2879 |
-
|
| 2880 |
-
|
| 2881 |
-
|
| 2882 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2883 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2884 |
)
|
| 2885 |
-
|
| 2886 |
-
|
| 2887 |
-
|
| 2888 |
-
|
| 2889 |
-
|
| 2890 |
-
|
| 2891 |
-
|
| 2892 |
-
# R14-TQ2-PQ1-R1: recovery may attach evidence only to
|
| 2893 |
-
# an already-written claim. It is never authorized to create a
|
| 2894 |
-
# supplemental paragraph from a source merely to satisfy citation
|
| 2895 |
-
# survival. If no existing claim can be validated, leave prose
|
| 2896 |
-
# unchanged. Evidence coverage may later be reported as limited,
|
| 2897 |
-
# while citation correctness itself remains fail-closed.
|
| 2898 |
-
self._progress(pct, f"citation_recovery:{current_index}:{total_steps}")
|
| 2899 |
-
recovered = agent.recover_citations_on_existing_claims(
|
| 2900 |
en_resolved,
|
| 2901 |
-
|
| 2902 |
-
|
| 2903 |
-
|
| 2904 |
)
|
| 2905 |
-
if recovered != en_resolved:
|
| 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,
|
| 2912 |
-
task=sec,
|
| 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 |
-
|
| 2952 |
-
|
| 2953 |
-
|
| 2954 |
-
|
| 2955 |
-
en_resolved, data_metadata, section_name=display_name
|
| 2956 |
)
|
| 2957 |
-
if
|
| 2958 |
logging.warning(
|
| 2959 |
-
"
|
| 2960 |
-
display_name, len(
|
| 2961 |
-
sorted({str(row.get("type") or "") for row in study_fact_rows}),
|
| 2962 |
)
|
| 2963 |
-
|
| 2964 |
-
"scope":"
|
| 2965 |
-
"status":"deterministic_repair", "count":len(
|
| 2966 |
-
"examples":
|
| 2967 |
})
|
| 2968 |
-
|
| 2969 |
-
en_resolved,
|
| 2970 |
-
)
|
| 2971 |
-
elif mode == "amj":
|
| 2972 |
-
en_resolved, study_fact_rows = quant_engine.repair_quantitative_study_fact_contract(
|
| 2973 |
-
en_resolved, data_metadata, section_name=display_name
|
| 2974 |
)
|
| 2975 |
-
if
|
| 2976 |
logging.warning(
|
| 2977 |
-
"
|
| 2978 |
-
display_name, len(
|
| 2979 |
-
sorted({str(row.get("type") or "") for row in study_fact_rows}),
|
| 2980 |
)
|
| 2981 |
-
|
| 2982 |
-
"scope":"
|
| 2983 |
-
"status":"
|
| 2984 |
-
"examples":
|
| 2985 |
})
|
| 2986 |
-
|
| 2987 |
-
en_resolved,
|
| 2988 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2989 |
|
| 2990 |
# Recompute citation survival/claim demand from the exact text that
|
| 2991 |
# will be sealed, not from an earlier pre-cleanup intermediate.
|
|
@@ -3216,23 +3492,52 @@ Output only final academic prose. Do not report the word count."""
|
|
| 3216 |
final_keywords = list(final_front.get("keywords") or [])
|
| 3217 |
if not final_abstract or not final_keywords:
|
| 3218 |
raise RuntimeError("final_front_matter_missing")
|
| 3219 |
-
|
| 3220 |
mode, agent, quant_engine, final_abstract, data_metadata,
|
| 3221 |
section_name="Final Abstract",
|
| 3222 |
)
|
| 3223 |
-
if not final_abstract:
|
| 3224 |
-
raise RuntimeError("final_abstract_empty_after_study_fact_contract")
|
| 3225 |
if final_front_fact_rows:
|
|
|
|
| 3226 |
logging.warning(
|
| 3227 |
-
"Final Abstract
|
| 3228 |
mode, len(final_front_fact_rows),
|
| 3229 |
-
sorted({str(row.get("type") or "") for row in final_front_fact_rows}),
|
| 3230 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3231 |
quality_diagnostics.append({
|
| 3232 |
"scope":"final_abstract_study_fact_integrity", "name":"Final Abstract",
|
| 3233 |
-
"status":"
|
| 3234 |
"examples":final_front_fact_rows[:3],
|
| 3235 |
})
|
|
|
|
| 3236 |
full_draft_en = replace_abstract(full_draft_en, final_abstract)
|
| 3237 |
full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
|
| 3238 |
body_only, _reference_tail = agent.validate_reference_tail(
|
|
|
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import copy
|
| 11 |
import hashlib
|
| 12 |
import inspect
|
| 13 |
import json
|
|
|
|
| 913 |
def _repair_validate_study_fact_contract(
|
| 914 |
mode: str, agent, quant_engine, text: str, data_metadata: dict, *, section_name: str
|
| 915 |
):
|
| 916 |
+
"""S0-R1 compatibility gateway: inspect only and never mutate prose."""
|
| 917 |
value = str(text or '').strip()
|
| 918 |
diagnostics = []
|
| 919 |
if str(mode or '') == 'asq':
|
| 920 |
+
diagnostics = agent.inspect_qualitative_study_fact_contract(
|
|
|
|
|
|
|
|
|
|
| 921 |
value, data_metadata, section_name=section_name
|
| 922 |
)
|
| 923 |
elif str(mode or '') == 'amj':
|
| 924 |
+
diagnostics = quant_engine.inspect_quantitative_study_fact_contract(
|
|
|
|
|
|
|
|
|
|
| 925 |
value, data_metadata, section_name=section_name
|
| 926 |
)
|
| 927 |
return value, list(diagnostics or [])
|
| 928 |
|
| 929 |
+
@staticmethod
|
| 930 |
+
def _study_fact_violations(mode: str, agent, quant_engine, text: str, data_metadata: dict, *, section_name: str):
|
| 931 |
+
if str(mode or '') == 'asq':
|
| 932 |
+
return list(agent.inspect_qualitative_study_fact_contract(text, data_metadata, section_name=section_name) or [])
|
| 933 |
+
if str(mode or '') == 'amj':
|
| 934 |
+
return list(quant_engine.inspect_quantitative_study_fact_contract(text, data_metadata, section_name=section_name) or [])
|
| 935 |
+
return []
|
| 936 |
+
|
| 937 |
+
@staticmethod
|
| 938 |
+
def _section_retry_feedback(violations, *, limit=8) -> str:
|
| 939 |
+
rows = []
|
| 940 |
+
for item in list(violations or [])[:max(1, int(limit))]:
|
| 941 |
+
if not isinstance(item, dict):
|
| 942 |
+
rows.append(str(item)[:500])
|
| 943 |
+
continue
|
| 944 |
+
kind = str(item.get('type') or 'validation_issue')
|
| 945 |
+
detail = str(item.get('repair_instruction') or item.get('reason') or item.get('preview') or '')
|
| 946 |
+
actor = str(item.get('actor') or '')
|
| 947 |
+
expected = item.get('expected')
|
| 948 |
+
observed = item.get('observed')
|
| 949 |
+
parts = [kind]
|
| 950 |
+
if actor:
|
| 951 |
+
parts.append(f'actor={actor}')
|
| 952 |
+
if observed not in (None, ''):
|
| 953 |
+
parts.append(f'observed={observed}')
|
| 954 |
+
if expected not in (None, ''):
|
| 955 |
+
parts.append(f'expected={expected}')
|
| 956 |
+
if detail:
|
| 957 |
+
parts.append(detail[:360])
|
| 958 |
+
rows.append(' | '.join(parts))
|
| 959 |
+
return '\n'.join(f'- {row}' for row in rows)
|
| 960 |
+
|
| 961 |
+
@staticmethod
|
| 962 |
+
def _snapshot_section_citation_state(agent, citation_plan: dict, unit_name: str) -> dict:
|
| 963 |
+
"""Snapshot only Section-owned citation state so a rejected attempt cannot leak forward."""
|
| 964 |
+
return {
|
| 965 |
+
"used_citations": set(getattr(agent, "used_citations", set()) or set()),
|
| 966 |
+
"citation_audit_log": copy.deepcopy(list(getattr(agent, "citation_audit_log", []) or [])),
|
| 967 |
+
"citation_map": copy.deepcopy(dict(getattr(agent, "citation_map", {}) or {})),
|
| 968 |
+
"planned_items": copy.deepcopy(list(dict(citation_plan or {}).get(unit_name, []) or [])),
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
@staticmethod
|
| 972 |
+
def _restore_section_citation_state(agent, citation_plan: dict, unit_name: str, snapshot: dict) -> None:
|
| 973 |
+
"""Restore citation ownership after an invalid Section attempt without rewinding API counters."""
|
| 974 |
+
snap = dict(snapshot or {})
|
| 975 |
+
lock = getattr(agent, "lock", None)
|
| 976 |
+
if lock is None:
|
| 977 |
+
agent.used_citations = set(snap.get("used_citations") or set())
|
| 978 |
+
agent.citation_audit_log = copy.deepcopy(list(snap.get("citation_audit_log") or []))
|
| 979 |
+
agent.citation_map = copy.deepcopy(dict(snap.get("citation_map") or {}))
|
| 980 |
+
else:
|
| 981 |
+
with lock:
|
| 982 |
+
agent.used_citations = set(snap.get("used_citations") or set())
|
| 983 |
+
agent.citation_audit_log = copy.deepcopy(list(snap.get("citation_audit_log") or []))
|
| 984 |
+
agent.citation_map = copy.deepcopy(dict(snap.get("citation_map") or {}))
|
| 985 |
+
citation_plan[unit_name] = copy.deepcopy(list(snap.get("planned_items") or []))
|
| 986 |
+
setattr(agent, "_last_citation_semantic_violations", [])
|
| 987 |
+
|
| 988 |
+
@classmethod
|
| 989 |
+
def _section_validation_bundle(
|
| 990 |
+
cls, mode: str, agent, quant_engine, text: str, data_metadata: dict, *,
|
| 991 |
+
section_id: str, section_name: str, evidence_contract: str = "", include_critic: bool = True,
|
| 992 |
+
citation_violations=None,
|
| 993 |
+
) -> list[dict]:
|
| 994 |
+
"""Collect typed S0-R1 validation feedback without mutating manuscript prose."""
|
| 995 |
+
value = str(text or "")
|
| 996 |
+
violations = []
|
| 997 |
+
if include_critic:
|
| 998 |
+
verdict = agent.critique_section(
|
| 999 |
+
value, section_id=section_id, section_name=section_name, mode=mode,
|
| 1000 |
+
evidence_contract=evidence_contract,
|
| 1001 |
+
)
|
| 1002 |
+
if isinstance(verdict, dict) and verdict.get("pass") is False:
|
| 1003 |
+
violations.append({
|
| 1004 |
+
"type": "top_journal_critic",
|
| 1005 |
+
"reason": str(verdict.get("critical_issue") or "")[:800],
|
| 1006 |
+
"repair_instruction": str(verdict.get("repair_instruction") or "")[:1200],
|
| 1007 |
+
})
|
| 1008 |
+
violations.extend(list(agent.inspect_authorial_ai_meta(value) or []))
|
| 1009 |
+
violations.extend(cls._study_fact_violations(
|
| 1010 |
+
mode, agent, quant_engine, value, data_metadata, section_name=section_name
|
| 1011 |
+
))
|
| 1012 |
+
violations.extend(copy.deepcopy(list(citation_violations or [])))
|
| 1013 |
+
return violations
|
| 1014 |
+
|
| 1015 |
+
def _checkpoint_section_retry_exhausted(
|
| 1016 |
+
self, state: dict, *, section_id: str, section_name: str, violations, pct: int, attempts: int = 2
|
| 1017 |
+
) -> None:
|
| 1018 |
+
typed = copy.deepcopy(list(violations or []))
|
| 1019 |
+
state["section_retry_exhausted"] = {
|
| 1020 |
+
"section_id": str(section_id or ""),
|
| 1021 |
+
"section_name": str(section_name or ""),
|
| 1022 |
+
"attempts": max(1, int(attempts or 1)),
|
| 1023 |
+
"violations": typed[:12],
|
| 1024 |
+
"semantic_integrity_corruption": False,
|
| 1025 |
+
}
|
| 1026 |
+
self._checkpoint(
|
| 1027 |
+
"section_retry_exhausted", state, pct,
|
| 1028 |
+
display_node=f"section_retry_exhausted:{section_name}",
|
| 1029 |
+
)
|
| 1030 |
+
|
| 1031 |
@staticmethod
|
| 1032 |
def _section_integrity_hash(value: str) -> str:
|
| 1033 |
exact = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
|
|
| 1055 |
rendered = str(replacement or "").strip()
|
| 1056 |
return (value[:match.start()] + rendered + "\n\n" + value[match.end():].lstrip("\n")).strip() + "\n\n"
|
| 1057 |
|
| 1058 |
+
@staticmethod
|
| 1059 |
+
def _remove_top_level_section(manuscript: str, title: str) -> str:
|
| 1060 |
+
value = str(manuscript or "")
|
| 1061 |
+
pattern = re.compile(rf'(?ms)^#\s+{re.escape(str(title))}\s*$\n.*?(?=^#\s+[^#].*?$|\Z)')
|
| 1062 |
+
matches = list(pattern.finditer(value))
|
| 1063 |
+
if len(matches) != 1:
|
| 1064 |
+
raise RuntimeError(f"top_level_section_remove_count_invalid:{title}:{len(matches)}")
|
| 1065 |
+
match = matches[0]
|
| 1066 |
+
return (value[:match.start()] + value[match.end():].lstrip("\n")).strip() + "\n\n"
|
| 1067 |
+
|
| 1068 |
@classmethod
|
| 1069 |
def _validate_section_integrity_seal(cls, seal: dict, section_text: str, section_title: str) -> None:
|
| 1070 |
record = dict(seal or {})
|
|
|
|
| 1970 |
old_tokens = cls._paragraph_token_set(old)
|
| 1971 |
union = tokens | old_tokens
|
| 1972 |
similarity = len(tokens & old_tokens) / max(1, len(union))
|
| 1973 |
+
normalized_same = re.sub(r"\W+", " ", value).strip().casefold() == re.sub(r"\W+", " ", old).strip().casefold()
|
| 1974 |
+
if not normalized_same:
|
| 1975 |
continue
|
| 1976 |
old_citations = set(re.findall(r'\[CITE-\d+\]', old, flags=re.I))
|
| 1977 |
old_numerics = set(re.findall(r'(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?(?![A-Za-z])', old))
|
| 1978 |
# Never delete a paragraph carrying different evidence/facts.
|
| 1979 |
if citations == old_citations and numerics == old_numerics:
|
| 1980 |
+
duplicate = (1.0, old[:180])
|
| 1981 |
break
|
| 1982 |
if duplicate:
|
| 1983 |
diagnostics.append({"similarity": round(duplicate[0], 4), "removed_preview": value[:180]})
|
|
|
|
| 2222 |
working_keywords = list(working_front.get("keywords") or [])
|
| 2223 |
if not working_abstract or not working_keywords:
|
| 2224 |
raise RuntimeError("working_front_matter_missing")
|
| 2225 |
+
_, front_fact_rows = self._repair_validate_study_fact_contract(
|
| 2226 |
mode, agent, quant_engine, working_abstract, data_metadata,
|
| 2227 |
section_name="Working Abstract",
|
| 2228 |
)
|
|
|
|
|
|
|
| 2229 |
if front_fact_rows:
|
| 2230 |
+
feedback = self._section_retry_feedback(front_fact_rows)
|
| 2231 |
logging.warning(
|
| 2232 |
+
"Working Abstract fact validation retry | mode=%s count=%s types=%s",
|
| 2233 |
+
mode, len(front_fact_rows), sorted({str(row.get("type") or "") for row in front_fact_rows}),
|
|
|
|
| 2234 |
)
|
| 2235 |
+
working_front = agent.generate_working_front_matter(
|
| 2236 |
+
topic, keywords, research_logic_contract, synthesis_result,
|
| 2237 |
+
data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
|
| 2238 |
+
)
|
| 2239 |
+
working_abstract = str(working_front.get("abstract") or "").strip()
|
| 2240 |
+
working_keywords = list(working_front.get("keywords") or [])
|
| 2241 |
+
_, remaining_front_rows = self._repair_validate_study_fact_contract(
|
| 2242 |
+
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 2243 |
+
)
|
| 2244 |
+
if remaining_front_rows:
|
| 2245 |
+
raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining_front_rows[:3]}")
|
| 2246 |
quality_diagnostics.append({
|
| 2247 |
"scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
|
| 2248 |
+
"status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
|
|
|
|
| 2249 |
})
|
| 2250 |
+
if not working_abstract:
|
| 2251 |
+
raise RuntimeError("working_front_matter_empty_after_validation")
|
| 2252 |
agent.global_memory["working_abstract"] = working_abstract
|
| 2253 |
|
| 2254 |
self._progress(14, "outline_planning")
|
|
|
|
| 2478 |
working_abstract = str(state.get("working_abstract") or "").strip()
|
| 2479 |
working_keywords = list(state.get("working_keywords") or [])
|
| 2480 |
if working_abstract and not state.get("english_finalized"):
|
| 2481 |
+
_, resumed_front_rows = self._repair_validate_study_fact_contract(
|
| 2482 |
+
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 2483 |
+
)
|
| 2484 |
+
if resumed_front_rows:
|
| 2485 |
+
feedback = self._section_retry_feedback(resumed_front_rows)
|
| 2486 |
+
working_front = agent.generate_working_front_matter(
|
| 2487 |
+
topic, keywords, research_logic_contract, synthesis_result,
|
| 2488 |
+
data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
|
| 2489 |
+
)
|
| 2490 |
+
candidate_abstract = str(working_front.get("abstract") or "").strip()
|
| 2491 |
+
candidate_keywords = list(working_front.get("keywords") or [])
|
| 2492 |
+
_, remaining = self._repair_validate_study_fact_contract(
|
| 2493 |
+
mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
|
| 2494 |
+
)
|
| 2495 |
+
if remaining:
|
| 2496 |
+
raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
|
| 2497 |
+
working_abstract = candidate_abstract
|
| 2498 |
+
working_keywords = candidate_keywords or working_keywords
|
| 2499 |
state["working_abstract"] = working_abstract
|
| 2500 |
+
state["working_keywords"] = working_keywords
|
| 2501 |
if str(state.get("full_draft_en") or "").strip():
|
| 2502 |
state["full_draft_en"] = replace_abstract(str(state.get("full_draft_en") or ""), working_abstract)
|
| 2503 |
+
state["full_draft_en"] = self._replace_keywords(state["full_draft_en"], working_keywords)
|
| 2504 |
full_draft_en = str(state["full_draft_en"] or "")
|
| 2505 |
quality_diagnostics.append({
|
| 2506 |
"scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
|
| 2507 |
+
"status":"resume_section_retry_resolved", "count":len(resumed_front_rows), "examples":resumed_front_rows[:3],
|
|
|
|
| 2508 |
})
|
| 2509 |
state["quality_diagnostics"] = quality_diagnostics
|
| 2510 |
+
self._checkpoint("working_front_matter_fact_retry_resolved", state, 15)
|
| 2511 |
if working_abstract:
|
| 2512 |
agent.global_memory["working_abstract"] = working_abstract
|
| 2513 |
if self._preview_target_text == '':
|
| 2514 |
self._preview_target_text = str(state.get('full_draft_translated') or '')
|
| 2515 |
|
| 2516 |
+
# S0-R1 compatibility migration: old completed English is never silently
|
| 2517 |
+
# repaired. Unsealed sections are inspected only. Valid text receives a
|
| 2518 |
+
# seal; invalid text is removed from the completed set so only that owning
|
| 2519 |
+
# top-level section is regenerated by the normal Section Writer.
|
| 2520 |
seal_migration = False
|
| 2521 |
seal_text_changed = False
|
| 2522 |
+
retry_section_keys = set()
|
| 2523 |
for section_key, section_title in self._unique_top_level_sections(adaptive_structure):
|
| 2524 |
if not self._section_is_complete(adaptive_structure, completed_sections, section_key):
|
| 2525 |
continue
|
|
|
|
| 2532 |
and existing_seal.get("sha256") == self._section_integrity_hash(section_text)
|
| 2533 |
):
|
| 2534 |
continue
|
| 2535 |
+
violations = self._study_fact_violations(
|
| 2536 |
+
mode, agent, quant_engine, section_text, data_metadata,
|
|
|
|
|
|
|
|
|
|
| 2537 |
section_name=f"Resume sealed section: {section_title}",
|
| 2538 |
)
|
| 2539 |
+
try:
|
| 2540 |
+
agent.validate_enumeration_contract(
|
| 2541 |
+
section_text, section_name=f"Resume sealed section: {section_title}"
|
| 2542 |
)
|
| 2543 |
+
except Exception as exc:
|
| 2544 |
+
violations.append({
|
| 2545 |
+
"type":"enumeration_contract_invalid",
|
| 2546 |
+
"reason":str(exc)[:500], "preview":section_title,
|
| 2547 |
+
})
|
| 2548 |
+
ai_meta_rows = list(agent.inspect_authorial_ai_meta(section_text) or [])
|
| 2549 |
+
violations.extend(ai_meta_rows)
|
| 2550 |
+
if violations:
|
| 2551 |
+
retry_section_keys.add(str(section_key))
|
| 2552 |
translation_sections.pop(section_key, None)
|
| 2553 |
+
section_integrity_seals.pop(section_key, None)
|
| 2554 |
quality_diagnostics.append({
|
| 2555 |
"scope":"english_section_seal_migration", "name":section_title,
|
| 2556 |
+
"status":"local_section_retry_required", "count":len(violations),
|
| 2557 |
+
"examples":violations[:3],
|
| 2558 |
})
|
| 2559 |
+
continue
|
| 2560 |
section_integrity_seals[section_key] = self._build_section_integrity_seal(
|
| 2561 |
section_key, section_title, section_text
|
| 2562 |
)
|
| 2563 |
seal_migration = True
|
| 2564 |
+
|
| 2565 |
+
if retry_section_keys:
|
| 2566 |
+
# Remove only invalid unsealed top-level sections and their completed
|
| 2567 |
+
# unit markers. Other completed English stays byte-stable.
|
| 2568 |
+
for section_key, section_title in self._unique_top_level_sections(adaptive_structure):
|
| 2569 |
+
if str(section_key) not in retry_section_keys:
|
| 2570 |
+
continue
|
| 2571 |
+
if self._extract_top_level_section(full_draft_en, section_title):
|
| 2572 |
+
full_draft_en = self._remove_top_level_section(full_draft_en, section_title)
|
| 2573 |
+
seal_text_changed = True
|
| 2574 |
+
relevant_names = {
|
| 2575 |
+
str(task.get('display_name') or '')
|
| 2576 |
+
for task in adaptive_structure
|
| 2577 |
+
if str(task.get('section_id') or task.get('section') or '') == str(section_key)
|
| 2578 |
+
}
|
| 2579 |
+
completed_sections = [name for name in completed_sections if name not in relevant_names]
|
| 2580 |
+
seal_migration = True
|
| 2581 |
+
|
| 2582 |
if seal_migration:
|
| 2583 |
+
# Rebuild mutable manuscript memory only from still-completed sealed English text.
|
|
|
|
| 2584 |
for memory_key in ("hypotheses", "variables", "model_summary", "method_summary"):
|
| 2585 |
agent.global_memory[memory_key] = ""
|
| 2586 |
last_sealed = ""
|
|
|
|
| 2594 |
if last_sealed:
|
| 2595 |
context_tail = " ".join(last_sealed.split()[-200:])
|
| 2596 |
state.update({
|
| 2597 |
+
"completed_sections": completed_sections,
|
| 2598 |
"full_draft_en": full_draft_en,
|
| 2599 |
"translation_sections": translation_sections,
|
| 2600 |
"section_integrity_seals": section_integrity_seals,
|
|
|
|
| 2674 |
working_keywords = list(working_front.get("keywords") or [])
|
| 2675 |
if not working_abstract or not working_keywords:
|
| 2676 |
raise RuntimeError("working_front_matter_missing")
|
| 2677 |
+
_, front_fact_rows = self._repair_validate_study_fact_contract(
|
| 2678 |
+
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
|
|
|
| 2679 |
)
|
|
|
|
|
|
|
| 2680 |
if front_fact_rows:
|
| 2681 |
+
feedback = self._section_retry_feedback(front_fact_rows)
|
| 2682 |
+
working_front = agent.generate_working_front_matter(
|
| 2683 |
+
topic, keywords, research_logic_contract, synthesis_result,
|
| 2684 |
+
data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
|
| 2685 |
+
)
|
| 2686 |
+
working_abstract = str(working_front.get("abstract") or "").strip()
|
| 2687 |
+
working_keywords = list(working_front.get("keywords") or [])
|
| 2688 |
+
_, remaining = self._repair_validate_study_fact_contract(
|
| 2689 |
+
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 2690 |
+
)
|
| 2691 |
+
if remaining:
|
| 2692 |
+
raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
|
| 2693 |
quality_diagnostics.append({
|
| 2694 |
"scope":"working_abstract_study_fact_integrity", "name":"Working Abstract",
|
| 2695 |
+
"status":"section_retry_resolved", "count":len(front_fact_rows), "examples":front_fact_rows[:3],
|
|
|
|
| 2696 |
})
|
| 2697 |
+
if not working_abstract:
|
| 2698 |
+
raise RuntimeError("working_front_matter_empty_after_validation")
|
| 2699 |
agent.global_memory["working_abstract"] = working_abstract
|
| 2700 |
full_draft_en = replace_abstract(full_draft_en, working_abstract)
|
| 2701 |
full_draft_en = self._replace_keywords(full_draft_en, working_keywords)
|
|
|
|
| 2977 |
prefix += f"# {sec_title}\n\n"
|
| 2978 |
if sec.get("subsection_first") and subsection:
|
| 2979 |
prefix += f"## {subsection}\n\n"
|
| 2980 |
+
prior_retry_exhausted = state.get("section_retry_exhausted")
|
| 2981 |
+
if (
|
| 2982 |
+
isinstance(prior_retry_exhausted, dict)
|
| 2983 |
+
and str(prior_retry_exhausted.get("section_name") or "") == display_name
|
| 2984 |
+
):
|
| 2985 |
+
# S0-R1 deliberately does not invent an executor/network retry state.
|
| 2986 |
+
# A content-invalid Section that exhausted its one local retry remains
|
| 2987 |
+
# durable and fail-closed until a separately approved lifecycle change.
|
| 2988 |
+
raise RuntimeError(f"section_retry_exhausted_requires_new_lifecycle:{display_name}")
|
| 2989 |
+
|
| 2990 |
+
deterministic_appendix = mode == "asq" and section_id == "appendix"
|
| 2991 |
+
max_section_attempts = 1 if deterministic_appendix else 2
|
| 2992 |
+
frozen_citation_state = self._snapshot_section_citation_state(
|
| 2993 |
+
agent, citation_plan, display_name
|
| 2994 |
+
)
|
| 2995 |
+
attempt_feedback = ""
|
| 2996 |
+
en_resolved = ""
|
| 2997 |
+
survived = set()
|
| 2998 |
+
|
| 2999 |
+
for section_attempt in range(1, max_section_attempts + 1):
|
| 3000 |
+
# Every attempt starts from exactly the same evidence/citation packet.
|
| 3001 |
+
# Rejected attempts may not leak used tags, JIT references, or audit
|
| 3002 |
+
# decisions into the retry or any later Section.
|
| 3003 |
+
if section_attempt > 1:
|
| 3004 |
+
self._restore_section_citation_state(
|
| 3005 |
+
agent, citation_plan, display_name, frozen_citation_state
|
| 3006 |
+
)
|
| 3007 |
+
planned_items = copy.deepcopy(list(citation_plan.get(display_name, []) or []))
|
| 3008 |
+
citation_plan[display_name] = planned_items
|
| 3009 |
+
attempt_diagnostics = []
|
| 3010 |
+
candidate_user_prompt = user_prompt
|
| 3011 |
+
if attempt_feedback:
|
| 3012 |
+
candidate_user_prompt += f"""
|
| 3013 |
+
|
| 3014 |
+
<SECTION_RETRY_VALIDATION_FEEDBACK>
|
| 3015 |
+
The previous attempt for this same manuscript unit was rejected by validation. Rewrite ONLY this same unit once. Keep the identical frozen evidence packet, research logic, section role, and citation plan. Do not fetch, invent, or broaden evidence. Resolve every material issue below without changing the task or manuscript scope:
|
| 3016 |
+
{attempt_feedback}
|
| 3017 |
+
</SECTION_RETRY_VALIDATION_FEEDBACK>"""
|
| 3018 |
+
if agent._count_tokens(sys_prompt) + agent._count_tokens(candidate_user_prompt) > 60000:
|
| 3019 |
+
candidate_user_prompt = agent._truncate_context(
|
| 3020 |
+
candidate_user_prompt, 60000 - agent._count_tokens(sys_prompt)
|
| 3021 |
+
)
|
| 3022 |
+
|
| 3023 |
+
if deterministic_appendix:
|
| 3024 |
+
# R6A-R4: Appendix is a deterministic reader-facing projection
|
| 3025 |
+
# of the Evidence Ledger. It is never regenerated by the LLM.
|
| 3026 |
+
en_raw = qual_engine.build_appendix_evidence_md(data_metadata)
|
| 3027 |
+
else:
|
| 3028 |
+
en_raw = self._stream_text(
|
| 3029 |
+
agent.stream_deepseek(
|
| 3030 |
+
sys_prompt, candidate_user_prompt, max_tokens=7000,
|
| 3031 |
+
model_role="writing", thinking=False, allow_fallback=False,
|
| 3032 |
+
),
|
| 3033 |
+
preview_prefix=full_draft_en + prefix,
|
| 3034 |
+
preview_stage=(display_name if section_attempt == 1 else f"{display_name}:retry"),
|
| 3035 |
+
)
|
| 3036 |
+
en_raw = re.sub(r"<think>.*?</think>", "", en_raw, flags=re.S).strip()
|
| 3037 |
+
en_raw = re.sub(rf"(?im)^#\s*{re.escape(sec_title)}\s*\n+", "", en_raw).strip()
|
| 3038 |
+
if subsection:
|
| 3039 |
+
en_raw = re.sub(rf"(?im)^##\s*{re.escape(subsection)}\s*\n+", "", en_raw).strip()
|
| 3040 |
+
en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
|
| 3041 |
+
en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
|
| 3042 |
+
en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
|
| 3043 |
+
if mode == "asq":
|
| 3044 |
+
# Model prose has no authority over quote presence/count.
|
| 3045 |
+
en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
|
| 3046 |
+
|
| 3047 |
+
# S0-R1 phase 1: feedback-only critic + pure semantic validators.
|
| 3048 |
+
pre_validation_rows = self._section_validation_bundle(
|
| 3049 |
+
mode, agent, quant_engine, prefix + en_raw, data_metadata,
|
| 3050 |
+
section_id=section_id, section_name=display_name,
|
| 3051 |
evidence_contract=evidence_packet,
|
| 3052 |
+
include_critic=not deterministic_appendix,
|
| 3053 |
+
)
|
| 3054 |
+
if pre_validation_rows:
|
| 3055 |
+
if section_attempt < max_section_attempts:
|
| 3056 |
+
attempt_feedback = self._section_retry_feedback(pre_validation_rows)
|
| 3057 |
+
logging.warning(
|
| 3058 |
+
"Section validation retry | unit=%s attempt=%s count=%s types=%s",
|
| 3059 |
+
display_name, section_attempt, len(pre_validation_rows),
|
| 3060 |
+
sorted({str(row.get("type") or "") for row in pre_validation_rows if isinstance(row, dict)}),
|
| 3061 |
+
)
|
| 3062 |
+
self._restore_section_citation_state(
|
| 3063 |
+
agent, citation_plan, display_name, frozen_citation_state
|
| 3064 |
+
)
|
| 3065 |
+
continue
|
| 3066 |
+
self._restore_section_citation_state(
|
| 3067 |
+
agent, citation_plan, display_name, frozen_citation_state
|
| 3068 |
+
)
|
| 3069 |
+
state.update({
|
| 3070 |
+
"citation_plan": citation_plan,
|
| 3071 |
+
"agent_state": agent.export_state(),
|
| 3072 |
+
"quality_diagnostics": quality_diagnostics,
|
| 3073 |
+
})
|
| 3074 |
+
self._checkpoint_section_retry_exhausted(
|
| 3075 |
+
state, section_id=section_id, section_name=display_name,
|
| 3076 |
+
violations=pre_validation_rows, pct=pct, attempts=max_section_attempts,
|
| 3077 |
+
)
|
| 3078 |
+
raise RuntimeError(
|
| 3079 |
+
f"section_content_invalid_after_retry:{display_name}:{pre_validation_rows[:3]}"
|
| 3080 |
+
)
|
| 3081 |
+
|
| 3082 |
+
allow_deterministic_citation_reinforcement = not (
|
| 3083 |
+
mode == "asq" and section_id in {"methods", "discussion"}
|
| 3084 |
)
|
| 3085 |
+
if not is_quarantined and assigned_tag_list and allow_deterministic_citation_reinforcement:
|
| 3086 |
+
en_raw = agent.reinforce_claim_specific_citation_coverage(
|
| 3087 |
+
en_raw, assigned_tag_list, section_name=display_name
|
| 3088 |
+
)
|
| 3089 |
+
self._progress(pct, f"citation_audit:{current_index}:{total_steps}")
|
| 3090 |
+
used_before = set(agent.used_citations)
|
| 3091 |
+
en_resolved = agent.audit_and_resolve_citations(
|
| 3092 |
+
prefix + en_raw, section_name=display_name, quarantine=is_quarantined
|
| 3093 |
+
)
|
| 3094 |
+
# Capture immediately: citation recovery may later replace the
|
| 3095 |
+
# agent's last-violation buffer. These violations belong to the
|
| 3096 |
+
# writer attempt and require the one bounded same-Section retry.
|
| 3097 |
+
citation_semantic_rows = copy.deepcopy(
|
| 3098 |
+
list(getattr(agent, "_last_citation_semantic_violations", []) or [])
|
| 3099 |
+
)
|
| 3100 |
+
if citation_semantic_rows:
|
| 3101 |
+
if section_attempt < max_section_attempts:
|
| 3102 |
+
attempt_feedback = self._section_retry_feedback(citation_semantic_rows)
|
| 3103 |
+
logging.warning(
|
| 3104 |
+
"Citation semantic Section retry | unit=%s attempt=%s count=%s types=%s",
|
| 3105 |
+
display_name, section_attempt, len(citation_semantic_rows),
|
| 3106 |
+
sorted({str(row.get("type") or "") for row in citation_semantic_rows if isinstance(row, dict)}),
|
| 3107 |
+
)
|
| 3108 |
+
self._restore_section_citation_state(
|
| 3109 |
+
agent, citation_plan, display_name, frozen_citation_state
|
| 3110 |
+
)
|
| 3111 |
+
continue
|
| 3112 |
+
self._restore_section_citation_state(
|
| 3113 |
+
agent, citation_plan, display_name, frozen_citation_state
|
| 3114 |
+
)
|
| 3115 |
+
state.update({
|
| 3116 |
+
"citation_plan": citation_plan,
|
| 3117 |
+
"agent_state": agent.export_state(),
|
| 3118 |
+
"quality_diagnostics": quality_diagnostics,
|
| 3119 |
+
})
|
| 3120 |
+
self._checkpoint_section_retry_exhausted(
|
| 3121 |
+
state, section_id=section_id, section_name=display_name,
|
| 3122 |
+
violations=citation_semantic_rows, pct=pct, attempts=max_section_attempts,
|
| 3123 |
+
)
|
| 3124 |
+
raise RuntimeError(
|
| 3125 |
+
f"section_citation_invalid_after_retry:{display_name}:{citation_semantic_rows[:3]}"
|
| 3126 |
+
)
|
| 3127 |
+
|
| 3128 |
+
used_after = set(agent.used_citations)
|
| 3129 |
+
survived = used_after - used_before
|
| 3130 |
+
if assigned_tag_list and not is_quarantined:
|
| 3131 |
+
required_survival = min(
|
| 3132 |
+
len(assigned_tag_list),
|
| 3133 |
+
max(1, int(math.ceil(len(assigned_tag_list) * CITATION_SEMANTIC_SURVIVAL_RATIO))),
|
| 3134 |
)
|
| 3135 |
+
if len(survived) < required_survival:
|
| 3136 |
+
missing_items = [
|
| 3137 |
+
item for item in planned_items
|
| 3138 |
+
if str(item.get("tag") or "") not in used_after
|
| 3139 |
+
]
|
| 3140 |
+
needed = max(0, required_survival - len(survived))
|
| 3141 |
+
recovery_items = agent.select_contextual_citation_recovery_items(
|
| 3142 |
+
missing_items, section_name=display_name, section_context=en_resolved,
|
| 3143 |
+
needed=needed, excluded_tags=used_after,
|
| 3144 |
+
)
|
| 3145 |
+
# Evidence retrieval is permitted only on the first writer
|
| 3146 |
+
# attempt. Attempt 2 is bound to the original frozen packet.
|
| 3147 |
+
if section_attempt == 1 and len(recovery_items) < needed:
|
| 3148 |
+
recovery_items.extend(
|
| 3149 |
+
agent.fetch_contextual_jit_recovery_items(
|
| 3150 |
+
topic=topic, section_name=display_name, section_context=en_resolved,
|
| 3151 |
+
needed=needed - len(recovery_items),
|
| 3152 |
+
excluded_tags=used_after | {str(item.get("tag") or "") for item in recovery_items},
|
| 3153 |
+
)
|
| 3154 |
)
|
| 3155 |
+
planned_tags_for_unit = {str(item.get("tag") or "") for item in planned_items}
|
| 3156 |
+
for recovery_item in recovery_items[:needed]:
|
| 3157 |
+
tag = str(recovery_item.get("tag") or "")
|
| 3158 |
+
if tag and tag not in planned_tags_for_unit:
|
| 3159 |
+
planned_items.append(recovery_item)
|
| 3160 |
+
planned_tags_for_unit.add(tag)
|
| 3161 |
+
citation_plan[display_name] = planned_items
|
| 3162 |
+
# Recovery may attach evidence only to an already-written
|
| 3163 |
+
# claim. It never creates supplemental prose.
|
| 3164 |
+
self._progress(pct, f"citation_recovery:{current_index}:{total_steps}")
|
| 3165 |
+
recovered = agent.recover_citations_on_existing_claims(
|
| 3166 |
+
en_resolved,
|
| 3167 |
+
recovery_items[:needed],
|
| 3168 |
+
section_name=display_name,
|
| 3169 |
+
needed=needed,
|
| 3170 |
)
|
| 3171 |
+
if recovered != en_resolved:
|
| 3172 |
+
en_resolved = recovered
|
| 3173 |
+
used_after = set(agent.used_citations)
|
| 3174 |
+
survived = used_after - used_before
|
| 3175 |
+
|
| 3176 |
+
if mode == "asq" and section_id == "findings":
|
| 3177 |
+
en_resolved = self._render_planned_qualitative_quotes(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3178 |
en_resolved,
|
| 3179 |
+
task=sec,
|
| 3180 |
+
data_metadata=data_metadata,
|
| 3181 |
+
quote_invocation_plan=quote_invocation_plan,
|
| 3182 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3183 |
|
| 3184 |
+
# S0-R1 phase 2: only local/idempotent structural normalizers may
|
| 3185 |
+
# mutate the candidate before the final pure validation bundle.
|
| 3186 |
+
en_resolved, enumeration_rows = agent.repair_and_validate_enumeration_contract(
|
| 3187 |
+
en_resolved, section_name=display_name
|
|
|
|
| 3188 |
)
|
| 3189 |
+
if enumeration_rows:
|
| 3190 |
logging.warning(
|
| 3191 |
+
"Structural enumeration repaired | unit=%s count=%s details=%s",
|
| 3192 |
+
display_name, len(enumeration_rows), enumeration_rows[:3],
|
|
|
|
| 3193 |
)
|
| 3194 |
+
attempt_diagnostics.append({
|
| 3195 |
+
"scope":"structural_enumeration", "name":display_name,
|
| 3196 |
+
"status":"deterministic_repair", "count":len(enumeration_rows),
|
| 3197 |
+
"examples":enumeration_rows[:3],
|
| 3198 |
})
|
| 3199 |
+
en_resolved, duplicate_rows = self._remove_severe_cross_unit_duplicates(
|
| 3200 |
+
en_resolved, full_draft_en
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3201 |
)
|
| 3202 |
+
if duplicate_rows:
|
| 3203 |
logging.warning(
|
| 3204 |
+
"Exact cross-unit redundancy removed | unit=%s count=%s",
|
| 3205 |
+
display_name, len(duplicate_rows),
|
|
|
|
| 3206 |
)
|
| 3207 |
+
attempt_diagnostics.append({
|
| 3208 |
+
"scope":"manuscript_redundancy", "name":display_name,
|
| 3209 |
+
"status":"exact_duplicate_removed", "count":len(duplicate_rows),
|
| 3210 |
+
"examples":duplicate_rows[:3],
|
| 3211 |
})
|
| 3212 |
+
en_resolved, sentence_echo_rows = agent.remove_severe_cross_unit_sentence_echoes(
|
| 3213 |
+
en_resolved, full_draft_en
|
| 3214 |
)
|
| 3215 |
+
if sentence_echo_rows:
|
| 3216 |
+
logging.warning(
|
| 3217 |
+
"Exact cross-unit sentence echo removed | unit=%s count=%s",
|
| 3218 |
+
display_name, len(sentence_echo_rows),
|
| 3219 |
+
)
|
| 3220 |
+
attempt_diagnostics.append({
|
| 3221 |
+
"scope":"manuscript_sentence_echo", "name":display_name,
|
| 3222 |
+
"status":"exact_duplicate_removed", "count":len(sentence_echo_rows),
|
| 3223 |
+
"examples":sentence_echo_rows[:3],
|
| 3224 |
+
})
|
| 3225 |
+
|
| 3226 |
+
# Final Section validation is pure. Once it passes, the exact bytes
|
| 3227 |
+
# are eligible for the Section Integrity Seal and may never be
|
| 3228 |
+
# semantically rewritten by a later validator.
|
| 3229 |
+
post_validation_rows = self._section_validation_bundle(
|
| 3230 |
+
mode, agent, quant_engine, en_resolved, data_metadata,
|
| 3231 |
+
section_id=section_id, section_name=display_name,
|
| 3232 |
+
evidence_contract=evidence_packet, include_critic=False,
|
| 3233 |
+
)
|
| 3234 |
+
if post_validation_rows:
|
| 3235 |
+
if section_attempt < max_section_attempts:
|
| 3236 |
+
attempt_feedback = self._section_retry_feedback(post_validation_rows)
|
| 3237 |
+
logging.warning(
|
| 3238 |
+
"Post-normalization Section retry | unit=%s attempt=%s count=%s types=%s",
|
| 3239 |
+
display_name, section_attempt, len(post_validation_rows),
|
| 3240 |
+
sorted({str(row.get("type") or "") for row in post_validation_rows if isinstance(row, dict)}),
|
| 3241 |
+
)
|
| 3242 |
+
self._restore_section_citation_state(
|
| 3243 |
+
agent, citation_plan, display_name, frozen_citation_state
|
| 3244 |
+
)
|
| 3245 |
+
continue
|
| 3246 |
+
self._restore_section_citation_state(
|
| 3247 |
+
agent, citation_plan, display_name, frozen_citation_state
|
| 3248 |
+
)
|
| 3249 |
+
state.update({
|
| 3250 |
+
"citation_plan": citation_plan,
|
| 3251 |
+
"agent_state": agent.export_state(),
|
| 3252 |
+
"quality_diagnostics": quality_diagnostics,
|
| 3253 |
+
})
|
| 3254 |
+
self._checkpoint_section_retry_exhausted(
|
| 3255 |
+
state, section_id=section_id, section_name=display_name,
|
| 3256 |
+
violations=post_validation_rows, pct=pct, attempts=max_section_attempts,
|
| 3257 |
+
)
|
| 3258 |
+
raise RuntimeError(
|
| 3259 |
+
f"section_content_invalid_after_retry:{display_name}:{post_validation_rows[:3]}"
|
| 3260 |
+
)
|
| 3261 |
+
|
| 3262 |
+
quality_diagnostics.extend(attempt_diagnostics)
|
| 3263 |
+
state.pop("section_retry_exhausted", None)
|
| 3264 |
+
break
|
| 3265 |
|
| 3266 |
# Recompute citation survival/claim demand from the exact text that
|
| 3267 |
# will be sealed, not from an earlier pre-cleanup intermediate.
|
|
|
|
| 3492 |
final_keywords = list(final_front.get("keywords") or [])
|
| 3493 |
if not final_abstract or not final_keywords:
|
| 3494 |
raise RuntimeError("final_front_matter_missing")
|
| 3495 |
+
final_front_fact_rows = self._study_fact_violations(
|
| 3496 |
mode, agent, quant_engine, final_abstract, data_metadata,
|
| 3497 |
section_name="Final Abstract",
|
| 3498 |
)
|
|
|
|
|
|
|
| 3499 |
if final_front_fact_rows:
|
| 3500 |
+
feedback = self._section_retry_feedback(final_front_fact_rows)
|
| 3501 |
logging.warning(
|
| 3502 |
+
"Final Abstract pure-validation retry | mode=%s count=%s types=%s",
|
| 3503 |
mode, len(final_front_fact_rows),
|
| 3504 |
+
sorted({str(row.get("type") or "") for row in final_front_fact_rows if isinstance(row, dict)}),
|
| 3505 |
+
)
|
| 3506 |
+
final_front = agent.generate_final_front_matter(
|
| 3507 |
+
topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
|
| 3508 |
+
validation_feedback=feedback,
|
| 3509 |
+
)
|
| 3510 |
+
final_abstract = str(final_front.get("abstract") or "").strip()
|
| 3511 |
+
final_keywords = list(final_front.get("keywords") or [])
|
| 3512 |
+
if not final_abstract or not final_keywords:
|
| 3513 |
+
raise RuntimeError("final_front_matter_missing_after_retry")
|
| 3514 |
+
remaining_final_fact_rows = self._study_fact_violations(
|
| 3515 |
+
mode, agent, quant_engine, final_abstract, data_metadata,
|
| 3516 |
+
section_name="Final Abstract",
|
| 3517 |
+
)
|
| 3518 |
+
if remaining_final_fact_rows:
|
| 3519 |
+
state["final_abstract_retry_exhausted"] = {
|
| 3520 |
+
"attempts": 2,
|
| 3521 |
+
"violations": copy.deepcopy(remaining_final_fact_rows[:12]),
|
| 3522 |
+
"semantic_integrity_corruption": False,
|
| 3523 |
+
}
|
| 3524 |
+
state.update({
|
| 3525 |
+
"agent_state": agent.export_state(),
|
| 3526 |
+
"quality_diagnostics": quality_diagnostics,
|
| 3527 |
+
})
|
| 3528 |
+
self._checkpoint(
|
| 3529 |
+
"final_abstract_retry_exhausted", state, 92,
|
| 3530 |
+
display_node="final_abstract_retry_exhausted",
|
| 3531 |
+
)
|
| 3532 |
+
raise RuntimeError(
|
| 3533 |
+
f"final_abstract_invalid_after_retry:{remaining_final_fact_rows[:3]}"
|
| 3534 |
+
)
|
| 3535 |
quality_diagnostics.append({
|
| 3536 |
"scope":"final_abstract_study_fact_integrity", "name":"Final Abstract",
|
| 3537 |
+
"status":"front_matter_retry_resolved", "count":len(final_front_fact_rows),
|
| 3538 |
"examples":final_front_fact_rows[:3],
|
| 3539 |
})
|
| 3540 |
+
state.pop("final_abstract_retry_exhausted", None)
|
| 3541 |
full_draft_en = replace_abstract(full_draft_en, final_abstract)
|
| 3542 |
full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
|
| 3543 |
body_only, _reference_tail = agent.validate_reference_tail(
|
core/pipeline_agent.py
CHANGED
|
@@ -1468,7 +1468,7 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
|
|
| 1468 |
raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
|
| 1469 |
|
| 1470 |
def generate_working_front_matter(
|
| 1471 |
-
self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj'
|
| 1472 |
):
|
| 1473 |
"""Create a provisional visible abstract/keyword anchor without prewriting results."""
|
| 1474 |
evidence = self._truncate_context(str(data_evidence or ''), 3500)
|
|
@@ -1488,7 +1488,10 @@ USER KEYWORD SEEDS: {keywords}
|
|
| 1488 |
</LITERATURE_SYNTHESIS>
|
| 1489 |
<FROZEN_RESEARCH_EVIDENCE>
|
| 1490 |
{evidence}
|
| 1491 |
-
</FROZEN_RESEARCH_EVIDENCE>
|
|
|
|
|
|
|
|
|
|
| 1492 |
def validate_working(parsed):
|
| 1493 |
abstract = self._validate_working_abstract(parsed.get('abstract'))
|
| 1494 |
working_keywords = self._front_matter_keywords(parsed.get('keywords'))
|
|
@@ -1499,7 +1502,7 @@ USER KEYWORD SEEDS: {keywords}
|
|
| 1499 |
prompt, phase='Working', max_tokens=1100, validator=validate_working,
|
| 1500 |
)
|
| 1501 |
|
| 1502 |
-
def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj'):
|
| 1503 |
"""Generate final Abstract + Keywords only from the frozen completed body/evidence."""
|
| 1504 |
evidence = self._truncate_context(str(data_evidence or ''), 5000)
|
| 1505 |
body = self._abstract_manuscript_context(manuscript, 18000)
|
|
@@ -1514,11 +1517,17 @@ USER KEYWORD SEEDS: {keywords}
|
|
| 1514 |
</FROZEN_RESEARCH_EVIDENCE>
|
| 1515 |
<COMPLETED_FROZEN_MANUSCRIPT>
|
| 1516 |
{body}
|
| 1517 |
-
</COMPLETED_FROZEN_MANUSCRIPT>
|
|
|
|
|
|
|
|
|
|
| 1518 |
def validate_final(parsed):
|
| 1519 |
abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', str(parsed.get('abstract') or '')).strip()
|
| 1520 |
if self._front_matter_has_citation(abstract):
|
| 1521 |
raise DeepSeekRequestError('Final Abstract must not contain citations.')
|
|
|
|
|
|
|
|
|
|
| 1522 |
final_keywords = self._front_matter_keywords(parsed.get('keywords'))
|
| 1523 |
return {'abstract': abstract, 'keywords': final_keywords}
|
| 1524 |
|
|
@@ -1527,9 +1536,8 @@ USER KEYWORD SEEDS: {keywords}
|
|
| 1527 |
prompt, phase='Final', max_tokens=1200, validator=validate_final,
|
| 1528 |
)
|
| 1529 |
abstract = structured['abstract']
|
| 1530 |
-
abstract = self.polish_academic_section(abstract, 'Abstract', abstract=True, mode=mode)
|
| 1531 |
if self._front_matter_has_citation(abstract):
|
| 1532 |
-
raise DeepSeekRequestError('Final Abstract
|
| 1533 |
max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200)))
|
| 1534 |
if not abstract:
|
| 1535 |
raise DeepSeekRequestError('Final Abstract is empty after validation.')
|
|
@@ -3667,6 +3675,115 @@ INPUT:
|
|
| 3667 |
)
|
| 3668 |
return bool(strong.search(value))
|
| 3669 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3670 |
@classmethod
|
| 3671 |
def repair_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 3672 |
"""Repair only high-confidence *study-owned* qualitative fact violations.
|
|
@@ -3842,9 +3959,9 @@ INPUT:
|
|
| 3842 |
|
| 3843 |
@classmethod
|
| 3844 |
def validate_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 3845 |
-
|
| 3846 |
-
if
|
| 3847 |
-
raise RuntimeError(f"
|
| 3848 |
return True
|
| 3849 |
|
| 3850 |
@classmethod
|
|
@@ -3954,8 +4071,8 @@ INPUT:
|
|
| 3954 |
union = tokens | old_tokens
|
| 3955 |
similarity = len(tokens & old_tokens) / max(1, len(union))
|
| 3956 |
normalized_same = re.sub(r"\W+", " ", core).strip().casefold() == re.sub(r"\W+", " ", old).strip().casefold()
|
| 3957 |
-
if normalized_same
|
| 3958 |
-
duplicate = (1.0
|
| 3959 |
break
|
| 3960 |
if duplicate:
|
| 3961 |
replacements.append((start, end, ""))
|
|
@@ -3970,19 +4087,36 @@ INPUT:
|
|
| 3970 |
value = re.sub(r" {2,}", " ", value)
|
| 3971 |
return value.strip(), diagnostics
|
| 3972 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3973 |
def semantic_audit_claim_specific_citations(
|
| 3974 |
self,
|
| 3975 |
text,
|
| 3976 |
section_name="",
|
| 3977 |
):
|
| 3978 |
-
"""Audit
|
| 3979 |
|
| 3980 |
-
|
| 3981 |
-
|
| 3982 |
-
|
| 3983 |
-
|
| 3984 |
-
one unsupported sentence must not discard an otherwise valid paper.
|
| 3985 |
"""
|
|
|
|
| 3986 |
if not text:
|
| 3987 |
return text
|
| 3988 |
|
|
@@ -4158,83 +4292,62 @@ Return exactly:
|
|
| 4158 |
status = str(item.get("status", "")).strip().lower()
|
| 4159 |
revised = str(item.get("revised_text", "")).strip()
|
| 4160 |
if status not in {
|
| 4161 |
-
"ok",
|
| 4162 |
-
"split",
|
| 4163 |
-
"uncited_common_knowledge",
|
| 4164 |
-
"unsupported",
|
| 4165 |
}:
|
| 4166 |
raise DeepSeekRequestError(
|
| 4167 |
f"Invalid citation audit status for {record['id']}: {status!r}"
|
| 4168 |
)
|
| 4169 |
-
if status == "unsupported":
|
| 4170 |
-
reason = str(item.get("reason", ""))[:500]
|
| 4171 |
-
unsupported.append(
|
| 4172 |
-
{
|
| 4173 |
-
"id": record["id"],
|
| 4174 |
-
"sentence": record["original"][:500],
|
| 4175 |
-
"reason": reason,
|
| 4176 |
-
}
|
| 4177 |
-
)
|
| 4178 |
-
# An unsupported externally verifiable claim cannot remain in
|
| 4179 |
-
# the manuscript with an arbitrary citation. Remove only this
|
| 4180 |
-
# sentence and retain an auditable record of the decision.
|
| 4181 |
-
replacements[record["id"]] = ""
|
| 4182 |
-
self._append_citation_audit(
|
| 4183 |
-
section_name,
|
| 4184 |
-
"remove_unsupported_claim",
|
| 4185 |
-
record["original"],
|
| 4186 |
-
"",
|
| 4187 |
-
reason or "No candidate evidence directly supported the claim.",
|
| 4188 |
-
)
|
| 4189 |
-
continue
|
| 4190 |
-
if not revised:
|
| 4191 |
-
raise DeepSeekRequestError(
|
| 4192 |
-
f"Citation semantic audit returned empty text for {record['id']}"
|
| 4193 |
-
)
|
| 4194 |
revised_tags = {
|
| 4195 |
f"[{tag.upper()}]"
|
| 4196 |
for tag in re.findall(r"CITE-\d+", revised, flags=re.IGNORECASE)
|
| 4197 |
}
|
| 4198 |
-
if status in {"ok", "split"} and not revised_tags:
|
| 4199 |
-
raise DeepSeekRequestError(
|
| 4200 |
-
f"Citation semantic audit removed all citation tags for {record['id']} "
|
| 4201 |
-
f"while status={status}."
|
| 4202 |
-
)
|
| 4203 |
disallowed = revised_tags - set(record["allowed_tags"])
|
| 4204 |
if disallowed:
|
| 4205 |
raise DeepSeekRequestError(
|
| 4206 |
f"Citation semantic audit invented tag(s) for {record['id']}: "
|
| 4207 |
f"{sorted(disallowed)}"
|
| 4208 |
)
|
| 4209 |
-
|
| 4210 |
-
|
| 4211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4212 |
)
|
| 4213 |
-
|
| 4214 |
-
|
| 4215 |
-
|
| 4216 |
-
|
| 4217 |
-
|
| 4218 |
-
|
| 4219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4220 |
)
|
| 4221 |
-
|
| 4222 |
-
if
|
|
|
|
|
|
|
|
|
|
| 4223 |
raise DeepSeekRequestError(
|
| 4224 |
-
f"Citation semantic audit
|
| 4225 |
-
f"{sentence_violations[:2]}"
|
| 4226 |
)
|
| 4227 |
-
|
| 4228 |
-
|
| 4229 |
-
revised,
|
| 4230 |
-
)
|
| 4231 |
|
| 4232 |
if unsupported:
|
| 4233 |
logging.warning(
|
| 4234 |
-
"Citation audit
|
| 4235 |
-
section_name,
|
| 4236 |
-
len(unsupported),
|
| 4237 |
-
json.dumps(unsupported[:3], ensure_ascii=False),
|
| 4238 |
)
|
| 4239 |
|
| 4240 |
rebuilt = []
|
|
@@ -4247,14 +4360,6 @@ Return exactly:
|
|
| 4247 |
audited = "".join(rebuilt)
|
| 4248 |
audited = re.sub(r"[ \t]+\n", "\n", audited)
|
| 4249 |
audited = re.sub(r"\n{3,}", "\n\n", audited).strip()
|
| 4250 |
-
if unsupported:
|
| 4251 |
-
repaired = self._repair_local_discourse_after_citation_removal(text, audited, section_name)
|
| 4252 |
-
if repaired != audited:
|
| 4253 |
-
self._append_citation_audit(
|
| 4254 |
-
section_name, "repair_local_discourse_framing", audited, repaired,
|
| 4255 |
-
"Renumbered surviving ordinal/enumeration framing after unsupported claim removal; no external claim added.",
|
| 4256 |
-
)
|
| 4257 |
-
audited = repaired
|
| 4258 |
violations = self.find_multi_citation_violations(audited)
|
| 4259 |
if violations:
|
| 4260 |
raise DeepSeekRequestError(
|
|
@@ -4290,13 +4395,9 @@ Return exactly:
|
|
| 4290 |
return coverage, overlap
|
| 4291 |
|
| 4292 |
def _local_citation_recovery(self, text, section_name='', retain_lexical=True):
|
| 4293 |
-
"""Conservative
|
| 4294 |
-
|
| 4295 |
-
|
| 4296 |
-
first. A source is retained only when lexical evidence is sufficiently
|
| 4297 |
-
strong; otherwise the externally verifiable sentence is removed and the
|
| 4298 |
-
decision is written to the citation audit log.
|
| 4299 |
-
"""
|
| 4300 |
rebuilt = []
|
| 4301 |
cursor = 0
|
| 4302 |
for start, end, sentence in self._sentence_spans(text):
|
|
@@ -4306,79 +4407,38 @@ Return exactly:
|
|
| 4306 |
unique_tags = list(dict.fromkeys(tags))
|
| 4307 |
valid = [tag for tag in unique_tags if tag in self.citation_map]
|
| 4308 |
revised = sentence
|
| 4309 |
-
|
| 4310 |
if unique_tags and not valid:
|
| 4311 |
-
revised =
|
| 4312 |
-
|
| 4313 |
-
self._append_citation_audit(
|
| 4314 |
-
section_name,
|
| 4315 |
-
'remove_unknown_tag',
|
| 4316 |
-
original,
|
| 4317 |
-
revised,
|
| 4318 |
-
'No valid citation tag was available.',
|
| 4319 |
-
)
|
| 4320 |
elif len(valid) == 1:
|
| 4321 |
selected_tag = valid[0]
|
| 4322 |
coverage, overlap = self._citation_support_score(sentence, selected_tag)
|
| 4323 |
if retain_lexical and overlap >= 2 and coverage >= 0.12:
|
| 4324 |
-
self.
|
| 4325 |
-
section_name,
|
| 4326 |
-
'retain_lexically_supported_source',
|
| 4327 |
-
original,
|
| 4328 |
-
sentence,
|
| 4329 |
-
f'Lexical support overlap={overlap}, coverage={coverage:.3f}.',
|
| 4330 |
-
)
|
| 4331 |
else:
|
| 4332 |
-
revised =
|
| 4333 |
-
self.
|
| 4334 |
-
|
| 4335 |
-
'
|
| 4336 |
-
|
| 4337 |
-
|
| 4338 |
-
f'The single candidate did not meet the conservative support threshold; overlap={overlap}, coverage={coverage:.3f}.',
|
| 4339 |
-
)
|
| 4340 |
elif len(valid) > 1:
|
| 4341 |
scored = sorted(
|
| 4342 |
-
(
|
| 4343 |
-
(*self._citation_support_score(sentence, tag), -self._citation_tag_number(tag), tag)
|
| 4344 |
-
for tag in valid
|
| 4345 |
-
),
|
| 4346 |
reverse=True,
|
| 4347 |
)
|
| 4348 |
coverage, overlap, _, selected_tag = scored[0]
|
| 4349 |
if retain_lexical and overlap >= 2 and coverage >= 0.12:
|
| 4350 |
-
revised =
|
| 4351 |
-
|
| 4352 |
-
' ',
|
| 4353 |
-
sentence,
|
| 4354 |
-
flags=re.I,
|
| 4355 |
-
)
|
| 4356 |
-
revised = re.sub(r'\s+', ' ', revised).strip()
|
| 4357 |
-
punctuation = revised[-1] if revised and revised[-1] in '.!?。!?' else ''
|
| 4358 |
-
if punctuation:
|
| 4359 |
-
revised = revised[:-1].rstrip()
|
| 4360 |
-
revised = f"{revised} {selected_tag}{punctuation}"
|
| 4361 |
-
self._append_citation_audit(
|
| 4362 |
-
section_name,
|
| 4363 |
-
'select_evidence_supported_source',
|
| 4364 |
-
original,
|
| 4365 |
-
revised,
|
| 4366 |
-
f'Lexical support overlap={overlap}, coverage={coverage:.3f}.',
|
| 4367 |
-
)
|
| 4368 |
else:
|
| 4369 |
-
revised =
|
| 4370 |
-
self.
|
| 4371 |
-
|
| 4372 |
-
'
|
| 4373 |
-
|
| 4374 |
-
|
| 4375 |
-
|
| 4376 |
-
)
|
| 4377 |
-
rebuilt.append(
|
| 4378 |
-
self._preserve_sentence_boundary_whitespace(original, revised)
|
| 4379 |
-
if revised
|
| 4380 |
-
else ""
|
| 4381 |
-
)
|
| 4382 |
cursor = end
|
| 4383 |
rebuilt.append(str(text)[cursor:])
|
| 4384 |
recovered = ''.join(rebuilt)
|
|
@@ -4461,6 +4521,7 @@ Return exactly:
|
|
| 4461 |
return revised
|
| 4462 |
|
| 4463 |
def audit_and_resolve_citations(self, text, section_name='', quarantine=False):
|
|
|
|
| 4464 |
value = str(text or '')
|
| 4465 |
if quarantine:
|
| 4466 |
return self.resolve_citations(value, quarantine=True)
|
|
@@ -4505,6 +4566,21 @@ Return exactly:
|
|
| 4505 |
)
|
| 4506 |
return self.resolve_citations(value, quarantine=False)
|
| 4507 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4508 |
@staticmethod
|
| 4509 |
def _remove_ai_meta_traces(text):
|
| 4510 |
"""Bounded deterministic repair for high-confidence authorial meta only.
|
|
@@ -4638,13 +4714,13 @@ TEXT:
|
|
| 4638 |
return value
|
| 4639 |
return revised.strip()
|
| 4640 |
|
| 4641 |
-
def
|
| 4642 |
-
"""Independent top-journal critic
|
| 4643 |
value = str(text or '').strip()
|
| 4644 |
mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
|
| 4645 |
critical_sections = ({'introduction', 'theory', 'discussion'} if mode == 'amj' else {'introduction', 'theoretical_framing', 'findings', 'discussion'})
|
| 4646 |
if str(section_id or '') not in critical_sections or len(value.split()) < 120:
|
| 4647 |
-
return
|
| 4648 |
dimensions = APP_CONFIG.get('top_journal_critic_profiles', {}).get(mode, [])
|
| 4649 |
profile = APP_CONFIG.get('section_reasoning_profiles', {}).get(mode, {}).get(str(section_id or ''), '')
|
| 4650 |
prompt = f"""Assess ONLY this manuscript unit as an independent top-journal critic. Return valid JSON only:
|
|
@@ -4660,35 +4736,30 @@ TEXT:
|
|
| 4660 |
)
|
| 4661 |
parsed = self._extract_json_from_text(raw)
|
| 4662 |
if not isinstance(parsed, dict) or parsed.get('pass') is not False:
|
| 4663 |
-
return
|
| 4664 |
-
|
|
|
|
| 4665 |
if not instruction:
|
| 4666 |
-
return
|
| 4667 |
-
|
| 4668 |
-
|
| 4669 |
-
|
| 4670 |
-
|
| 4671 |
-
|
| 4672 |
-
|
| 4673 |
-
|
| 4674 |
-
|
| 4675 |
-
|
| 4676 |
-
|
| 4677 |
-
|
| 4678 |
-
|
| 4679 |
-
|
| 4680 |
-
|
| 4681 |
-
|
| 4682 |
-
|
| 4683 |
-
|
| 4684 |
-
|
| 4685 |
-
|
| 4686 |
-
]
|
| 4687 |
-
if any(left != right for left, right in invariants):
|
| 4688 |
-
logging.warning('Top-journal local repair rejected because protected content changed: %s', section_name)
|
| 4689 |
-
return value
|
| 4690 |
-
logging.info('Top-journal local repair accepted: section=%s issue=%s', section_name, instruction[:180])
|
| 4691 |
-
return self._remove_ai_meta_traces(revised)
|
| 4692 |
|
| 4693 |
def _abstract_manuscript_context(self, manuscript, max_tokens=18000):
|
| 4694 |
value = str(manuscript or '').strip()
|
|
|
|
| 1468 |
raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
|
| 1469 |
|
| 1470 |
def generate_working_front_matter(
|
| 1471 |
+
self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback=''
|
| 1472 |
):
|
| 1473 |
"""Create a provisional visible abstract/keyword anchor without prewriting results."""
|
| 1474 |
evidence = self._truncate_context(str(data_evidence or ''), 3500)
|
|
|
|
| 1488 |
</LITERATURE_SYNTHESIS>
|
| 1489 |
<FROZEN_RESEARCH_EVIDENCE>
|
| 1490 |
{evidence}
|
| 1491 |
+
</FROZEN_RESEARCH_EVIDENCE>
|
| 1492 |
+
<LOCAL_VALIDATION_FEEDBACK>
|
| 1493 |
+
{str(validation_feedback or '')[:2500]}
|
| 1494 |
+
</LOCAL_VALIDATION_FEEDBACK>"""
|
| 1495 |
def validate_working(parsed):
|
| 1496 |
abstract = self._validate_working_abstract(parsed.get('abstract'))
|
| 1497 |
working_keywords = self._front_matter_keywords(parsed.get('keywords'))
|
|
|
|
| 1502 |
prompt, phase='Working', max_tokens=1100, validator=validate_working,
|
| 1503 |
)
|
| 1504 |
|
| 1505 |
+
def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj', validation_feedback=''):
|
| 1506 |
"""Generate final Abstract + Keywords only from the frozen completed body/evidence."""
|
| 1507 |
evidence = self._truncate_context(str(data_evidence or ''), 5000)
|
| 1508 |
body = self._abstract_manuscript_context(manuscript, 18000)
|
|
|
|
| 1517 |
</FROZEN_RESEARCH_EVIDENCE>
|
| 1518 |
<COMPLETED_FROZEN_MANUSCRIPT>
|
| 1519 |
{body}
|
| 1520 |
+
</COMPLETED_FROZEN_MANUSCRIPT>
|
| 1521 |
+
<LOCAL_VALIDATION_FEEDBACK>
|
| 1522 |
+
{str(validation_feedback or '')[:2500]}
|
| 1523 |
+
</LOCAL_VALIDATION_FEEDBACK>"""
|
| 1524 |
def validate_final(parsed):
|
| 1525 |
abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', str(parsed.get('abstract') or '')).strip()
|
| 1526 |
if self._front_matter_has_citation(abstract):
|
| 1527 |
raise DeepSeekRequestError('Final Abstract must not contain citations.')
|
| 1528 |
+
meta_findings = self.inspect_authorial_ai_meta(abstract)
|
| 1529 |
+
if meta_findings:
|
| 1530 |
+
raise DeepSeekRequestError('Final Abstract contains high-confidence authorial AI-writing meta-commentary.')
|
| 1531 |
final_keywords = self._front_matter_keywords(parsed.get('keywords'))
|
| 1532 |
return {'abstract': abstract, 'keywords': final_keywords}
|
| 1533 |
|
|
|
|
| 1536 |
prompt, phase='Final', max_tokens=1200, validator=validate_final,
|
| 1537 |
)
|
| 1538 |
abstract = structured['abstract']
|
|
|
|
| 1539 |
if self._front_matter_has_citation(abstract):
|
| 1540 |
+
raise DeepSeekRequestError('Final Abstract contains a citation after structured validation.')
|
| 1541 |
max_words = max(80, int(APP_CONFIG.get('manuscript_quality', {}).get('abstract_max_words', 200)))
|
| 1542 |
if not abstract:
|
| 1543 |
raise DeepSeekRequestError('Final Abstract is empty after validation.')
|
|
|
|
| 3675 |
)
|
| 3676 |
return bool(strong.search(value))
|
| 3677 |
|
| 3678 |
+
@classmethod
|
| 3679 |
+
def inspect_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 3680 |
+
"""Pure ASQ Study Evidence inspection. Never mutates manuscript prose.
|
| 3681 |
+
|
| 3682 |
+
S0-R1 deliberately prefers false negatives to destructive false positives.
|
| 3683 |
+
Actor/source violations are reported only when the sentence contains an
|
| 3684 |
+
explicit grammatical relation (for example ``interviews with farmers`` or
|
| 3685 |
+
``farmers were interviewed``). Mere proximity is not evidence ownership.
|
| 3686 |
+
External literature and participant quotations are excluded.
|
| 3687 |
+
"""
|
| 3688 |
+
value = str(text or "")
|
| 3689 |
+
facts = dict((metadata or {}).get("study_facts") or {})
|
| 3690 |
+
relations = [row for row in (facts.get("data_source_roles") or []) if isinstance(row, dict)]
|
| 3691 |
+
allowed_by_actor = {}
|
| 3692 |
+
for row in relations:
|
| 3693 |
+
role = re.sub(r"\s+", " ", str(row.get("actor_role") or "")).strip()
|
| 3694 |
+
family = cls._qual_source_family(row.get("data_source"))
|
| 3695 |
+
if not role or not family:
|
| 3696 |
+
continue
|
| 3697 |
+
for variant in cls._qual_actor_variants(role):
|
| 3698 |
+
allowed_by_actor.setdefault(variant, set()).add(family)
|
| 3699 |
+
|
| 3700 |
+
procedure_lists = {
|
| 3701 |
+
"trustworthiness": [str(x or "").strip().casefold() for x in (facts.get("trustworthiness_procedures") or []) if str(x or "").strip()],
|
| 3702 |
+
"ethics": [str(x or "").strip().casefold() for x in (facts.get("ethics_procedures") or []) if str(x or "").strip()],
|
| 3703 |
+
"reflexivity": [str(x or "").strip().casefold() for x in (facts.get("reflexivity_procedures") or []) if str(x or "").strip()],
|
| 3704 |
+
}
|
| 3705 |
+
procedure_patterns = {
|
| 3706 |
+
"trustworthiness": r"\bmember\s+check(?:ing|s)?\b|\bpeer\s+debrief(?:ing|ed)?\b|\baudit\s+trail\b|\bexternal\s+audit\b",
|
| 3707 |
+
"ethics": r"\binformed\s+consent\b|\bethics?\s+approval\b|\binstitutional\s+review\s+board\b|\bIRB\b|\bapproval\s+(?:number|code)\b",
|
| 3708 |
+
"reflexivity": r"\bfield\s+journal\b|\breflexive\s+journal\b|\bpositionality\s+(?:statement|memo)\b|\breflexive\s+memo(?:s)?\b",
|
| 3709 |
+
}
|
| 3710 |
+
source_patterns = {
|
| 3711 |
+
"interview": r"(?:semi[- ]structured\s+)?interviews?|interviewed",
|
| 3712 |
+
"focus_group": r"focus\s+groups?",
|
| 3713 |
+
"observation": r"(?:field\s+)?observations?|observed",
|
| 3714 |
+
"log": r"(?:usage|interaction|system)\s+logs?|log\s+data",
|
| 3715 |
+
"document": r"documents?|archival\s+(?:records?|materials?)|records?",
|
| 3716 |
+
}
|
| 3717 |
+
source_union = "(?:" + "|".join(source_patterns.values()) + ")"
|
| 3718 |
+
|
| 3719 |
+
def procedure_key(raw):
|
| 3720 |
+
observed = str(raw or "").casefold()
|
| 3721 |
+
if "member check" in observed: return "member check"
|
| 3722 |
+
if "peer debrief" in observed: return "peer debrief"
|
| 3723 |
+
if "audit trail" in observed: return "audit trail"
|
| 3724 |
+
if "external audit" in observed: return "external audit"
|
| 3725 |
+
if "informed consent" in observed: return "informed consent"
|
| 3726 |
+
if "ethic" in observed and "approval" in observed: return "ethics approval"
|
| 3727 |
+
if "review board" in observed or observed.strip() == "irb": return "review board"
|
| 3728 |
+
if "approval" in observed: return "approval"
|
| 3729 |
+
if "field journal" in observed: return "field journal"
|
| 3730 |
+
if "reflexive journal" in observed: return "reflexive journal"
|
| 3731 |
+
if "positionality" in observed: return "positionality"
|
| 3732 |
+
return "reflexive memo"
|
| 3733 |
+
|
| 3734 |
+
violations = []
|
| 3735 |
+
seen = set()
|
| 3736 |
+
for _start, _end, sentence in cls._sentence_spans(value):
|
| 3737 |
+
core = str(sentence or "")
|
| 3738 |
+
if not core.strip() or not cls._is_study_owned_qualitative_claim(core, section_name):
|
| 3739 |
+
continue
|
| 3740 |
+
|
| 3741 |
+
# Check every explicit procedure mention; do not stop after the first.
|
| 3742 |
+
for category, pattern in procedure_patterns.items():
|
| 3743 |
+
for match in re.finditer(pattern, core, flags=re.I):
|
| 3744 |
+
key = procedure_key(match.group(0))
|
| 3745 |
+
authorized = any(key in item for item in procedure_lists[category])
|
| 3746 |
+
if authorized:
|
| 3747 |
+
continue
|
| 3748 |
+
sig = ("procedure", category, key, match.start())
|
| 3749 |
+
if sig in seen:
|
| 3750 |
+
continue
|
| 3751 |
+
seen.add(sig)
|
| 3752 |
+
violations.append({
|
| 3753 |
+
"type":"unsupported_study_procedure", "category":category,
|
| 3754 |
+
"procedure":key, "preview":core.strip()[:180],
|
| 3755 |
+
})
|
| 3756 |
+
|
| 3757 |
+
# Explicit actor/source ownership only. These patterns intentionally
|
| 3758 |
+
# do not use nearest-distance heuristics.
|
| 3759 |
+
for actor_variant, allowed_families in allowed_by_actor.items():
|
| 3760 |
+
actor = re.escape(actor_variant)
|
| 3761 |
+
patterns = [
|
| 3762 |
+
rf"(?P<source>{source_union})\s+(?:with|of|from|among|involving)\s+(?:the\s+)?(?P<actor>{actor})(?![A-Za-z])",
|
| 3763 |
+
rf"(?P<source>interviewed|observed)\s+(?:the\s+)?(?P<actor>{actor})(?![A-Za-z])",
|
| 3764 |
+
rf"(?P<actor>(?<![A-Za-z]){actor}(?![A-Za-z]))\s+(?:were|was|are|is)\s+(?P<source>interviewed|observed)\b",
|
| 3765 |
+
rf"(?P<actor>(?<![A-Za-z]){actor}(?![A-Za-z]))\s+(?:participated|took\s+part)\s+in\s+(?P<source>{source_union})",
|
| 3766 |
+
rf"(?P<actor>(?<![A-Za-z]){actor}(?![A-Za-z]))\s+(?:provided|submitted|generated)\s+(?P<source>{source_union})",
|
| 3767 |
+
rf"(?P<source>interview|focus\s+group|observation)\s+participants?[^.!?\n]{{0,120}}(?P<actor>(?<![A-Za-z]){actor}(?![A-Za-z]))",
|
| 3768 |
+
]
|
| 3769 |
+
for pattern in patterns:
|
| 3770 |
+
for match in re.finditer(pattern, core, flags=re.I):
|
| 3771 |
+
raw_source = match.group("source")
|
| 3772 |
+
family = cls._qual_source_family(raw_source)
|
| 3773 |
+
if not family or family in allowed_families:
|
| 3774 |
+
continue
|
| 3775 |
+
sig = ("relation", actor_variant, family, match.start(), match.end())
|
| 3776 |
+
if sig in seen:
|
| 3777 |
+
continue
|
| 3778 |
+
seen.add(sig)
|
| 3779 |
+
violations.append({
|
| 3780 |
+
"type":"actor_data_source_relation_mismatch",
|
| 3781 |
+
"actor":actor_variant, "observed":family,
|
| 3782 |
+
"allowed":sorted(allowed_families),
|
| 3783 |
+
"preview":core.strip()[:180],
|
| 3784 |
+
})
|
| 3785 |
+
return violations
|
| 3786 |
+
|
| 3787 |
@classmethod
|
| 3788 |
def repair_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 3789 |
"""Repair only high-confidence *study-owned* qualitative fact violations.
|
|
|
|
| 3959 |
|
| 3960 |
@classmethod
|
| 3961 |
def validate_qualitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 3962 |
+
violations = cls.inspect_qualitative_study_fact_contract(text, metadata, section_name=section_name)
|
| 3963 |
+
if violations:
|
| 3964 |
+
raise RuntimeError(f"qualitative_study_fact_contract_invalid:{section_name}:{violations[:3]}")
|
| 3965 |
return True
|
| 3966 |
|
| 3967 |
@classmethod
|
|
|
|
| 4071 |
union = tokens | old_tokens
|
| 4072 |
similarity = len(tokens & old_tokens) / max(1, len(union))
|
| 4073 |
normalized_same = re.sub(r"\W+", " ", core).strip().casefold() == re.sub(r"\W+", " ", old).strip().casefold()
|
| 4074 |
+
if normalized_same:
|
| 4075 |
+
duplicate = (1.0, old[:180])
|
| 4076 |
break
|
| 4077 |
if duplicate:
|
| 4078 |
replacements.append((start, end, ""))
|
|
|
|
| 4087 |
value = re.sub(r" {2,}", " ", value)
|
| 4088 |
return value.strip(), diagnostics
|
| 4089 |
|
| 4090 |
+
@classmethod
|
| 4091 |
+
def _citation_tag_only_rewrite(cls, original, selected_tag=None):
|
| 4092 |
+
"""Change citation markup only; preserve academic claim wording."""
|
| 4093 |
+
source = str(original or "")
|
| 4094 |
+
leading = source[: len(source) - len(source.lstrip())]
|
| 4095 |
+
trailing = source[len(source.rstrip()):]
|
| 4096 |
+
core = source.strip()
|
| 4097 |
+
core = re.sub(r"\s*[\[(]?CITE-\d+[\])]?\s*", " ", core, flags=re.I)
|
| 4098 |
+
core = re.sub(r"[ \t]{2,}", " ", core).strip()
|
| 4099 |
+
if selected_tag:
|
| 4100 |
+
tag = str(selected_tag).strip()
|
| 4101 |
+
punctuation = core[-1] if core and core[-1] in '.!?。!?' else ''
|
| 4102 |
+
if punctuation:
|
| 4103 |
+
core = core[:-1].rstrip()
|
| 4104 |
+
core = f"{core} {tag}{punctuation}".strip()
|
| 4105 |
+
return f"{leading}{core}{trailing}"
|
| 4106 |
+
|
| 4107 |
def semantic_audit_claim_specific_citations(
|
| 4108 |
self,
|
| 4109 |
text,
|
| 4110 |
section_name="",
|
| 4111 |
):
|
| 4112 |
+
"""Audit citation support while granting the audit TAG-ONLY mutation authority.
|
| 4113 |
|
| 4114 |
+
S0-R1 forbids citation QA from rewriting or deleting academic claims.
|
| 4115 |
+
Unsupported or split-required claims are returned as typed section
|
| 4116 |
+
violations for the owning Section Writer retry; supported decisions may
|
| 4117 |
+
only select/remove citation tags.
|
|
|
|
| 4118 |
"""
|
| 4119 |
+
self._last_citation_semantic_violations = []
|
| 4120 |
if not text:
|
| 4121 |
return text
|
| 4122 |
|
|
|
|
| 4292 |
status = str(item.get("status", "")).strip().lower()
|
| 4293 |
revised = str(item.get("revised_text", "")).strip()
|
| 4294 |
if status not in {
|
| 4295 |
+
"ok", "split", "uncited_common_knowledge", "unsupported",
|
|
|
|
|
|
|
|
|
|
| 4296 |
}:
|
| 4297 |
raise DeepSeekRequestError(
|
| 4298 |
f"Invalid citation audit status for {record['id']}: {status!r}"
|
| 4299 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4300 |
revised_tags = {
|
| 4301 |
f"[{tag.upper()}]"
|
| 4302 |
for tag in re.findall(r"CITE-\d+", revised, flags=re.IGNORECASE)
|
| 4303 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4304 |
disallowed = revised_tags - set(record["allowed_tags"])
|
| 4305 |
if disallowed:
|
| 4306 |
raise DeepSeekRequestError(
|
| 4307 |
f"Citation semantic audit invented tag(s) for {record['id']}: "
|
| 4308 |
f"{sorted(disallowed)}"
|
| 4309 |
)
|
| 4310 |
+
reason = str(item.get("reason", ""))[:500]
|
| 4311 |
+
if status == "unsupported":
|
| 4312 |
+
violation = {
|
| 4313 |
+
"type":"citation_unsupported_claim", "id":record["id"],
|
| 4314 |
+
"sentence":record["original"][:500], "reason":reason,
|
| 4315 |
+
}
|
| 4316 |
+
unsupported.append(violation)
|
| 4317 |
+
self._last_citation_semantic_violations.append(violation)
|
| 4318 |
+
replacements[record["id"]] = self._citation_tag_only_rewrite(record["original"], None)
|
| 4319 |
+
self._append_citation_audit(
|
| 4320 |
+
section_name, "flag_unsupported_claim_for_section_retry",
|
| 4321 |
+
record["original"], replacements[record["id"]],
|
| 4322 |
+
reason or "No candidate evidence directly supported the claim.",
|
| 4323 |
)
|
| 4324 |
+
continue
|
| 4325 |
+
if status == "split":
|
| 4326 |
+
violation = {
|
| 4327 |
+
"type":"citation_claim_split_required", "id":record["id"],
|
| 4328 |
+
"sentence":record["original"][:500], "reason":reason,
|
| 4329 |
+
}
|
| 4330 |
+
self._last_citation_semantic_violations.append(violation)
|
| 4331 |
+
replacements[record["id"]] = self._citation_tag_only_rewrite(record["original"], None)
|
| 4332 |
+
self._append_citation_audit(
|
| 4333 |
+
section_name, "flag_claim_split_for_section_retry",
|
| 4334 |
+
record["original"], replacements[record["id"]], reason,
|
| 4335 |
)
|
| 4336 |
+
continue
|
| 4337 |
+
if status == "uncited_common_knowledge":
|
| 4338 |
+
replacements[record["id"]] = self._citation_tag_only_rewrite(record["original"], None)
|
| 4339 |
+
continue
|
| 4340 |
+
if len(revised_tags) != 1:
|
| 4341 |
raise DeepSeekRequestError(
|
| 4342 |
+
f"Citation semantic audit must select exactly one citation tag for {record['id']} while status=ok."
|
|
|
|
| 4343 |
)
|
| 4344 |
+
selected = next(iter(revised_tags))
|
| 4345 |
+
replacements[record["id"]] = self._citation_tag_only_rewrite(record["original"], selected)
|
|
|
|
|
|
|
| 4346 |
|
| 4347 |
if unsupported:
|
| 4348 |
logging.warning(
|
| 4349 |
+
"Citation audit flagged unsupported claims for section retry: section=%s count=%s examples=%s",
|
| 4350 |
+
section_name, len(unsupported), json.dumps(unsupported[:3], ensure_ascii=False),
|
|
|
|
|
|
|
| 4351 |
)
|
| 4352 |
|
| 4353 |
rebuilt = []
|
|
|
|
| 4360 |
audited = "".join(rebuilt)
|
| 4361 |
audited = re.sub(r"[ \t]+\n", "\n", audited)
|
| 4362 |
audited = re.sub(r"\n{3,}", "\n\n", audited).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4363 |
violations = self.find_multi_citation_violations(audited)
|
| 4364 |
if violations:
|
| 4365 |
raise DeepSeekRequestError(
|
|
|
|
| 4395 |
return coverage, overlap
|
| 4396 |
|
| 4397 |
def _local_citation_recovery(self, text, section_name='', retain_lexical=True):
|
| 4398 |
+
"""Conservative TAG-ONLY recovery for citation audit technical failures."""
|
| 4399 |
+
if not hasattr(self, '_last_citation_semantic_violations'):
|
| 4400 |
+
self._last_citation_semantic_violations = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4401 |
rebuilt = []
|
| 4402 |
cursor = 0
|
| 4403 |
for start, end, sentence in self._sentence_spans(text):
|
|
|
|
| 4407 |
unique_tags = list(dict.fromkeys(tags))
|
| 4408 |
valid = [tag for tag in unique_tags if tag in self.citation_map]
|
| 4409 |
revised = sentence
|
|
|
|
| 4410 |
if unique_tags and not valid:
|
| 4411 |
+
revised = self._citation_tag_only_rewrite(original, None)
|
| 4412 |
+
self._append_citation_audit(section_name, 'remove_unknown_tag', original, revised, 'No valid citation tag was available.')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4413 |
elif len(valid) == 1:
|
| 4414 |
selected_tag = valid[0]
|
| 4415 |
coverage, overlap = self._citation_support_score(sentence, selected_tag)
|
| 4416 |
if retain_lexical and overlap >= 2 and coverage >= 0.12:
|
| 4417 |
+
revised = self._citation_tag_only_rewrite(original, selected_tag)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4418 |
else:
|
| 4419 |
+
revised = self._citation_tag_only_rewrite(original, None)
|
| 4420 |
+
self._last_citation_semantic_violations.append({
|
| 4421 |
+
'type':'citation_support_unverified', 'sentence':str(original).strip()[:500],
|
| 4422 |
+
'reason':f'No verified citation support; overlap={overlap}, coverage={coverage:.3f}.',
|
| 4423 |
+
})
|
| 4424 |
+
self._append_citation_audit(section_name, 'flag_unverified_claim_for_section_retry', original, revised, f'overlap={overlap}, coverage={coverage:.3f}')
|
|
|
|
|
|
|
| 4425 |
elif len(valid) > 1:
|
| 4426 |
scored = sorted(
|
| 4427 |
+
((*self._citation_support_score(sentence, tag), -self._citation_tag_number(tag), tag) for tag in valid),
|
|
|
|
|
|
|
|
|
|
| 4428 |
reverse=True,
|
| 4429 |
)
|
| 4430 |
coverage, overlap, _, selected_tag = scored[0]
|
| 4431 |
if retain_lexical and overlap >= 2 and coverage >= 0.12:
|
| 4432 |
+
revised = self._citation_tag_only_rewrite(original, selected_tag)
|
| 4433 |
+
self._append_citation_audit(section_name, 'select_evidence_supported_source', original, revised, f'overlap={overlap}, coverage={coverage:.3f}')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4434 |
else:
|
| 4435 |
+
revised = self._citation_tag_only_rewrite(original, None)
|
| 4436 |
+
self._last_citation_semantic_violations.append({
|
| 4437 |
+
'type':'citation_support_unverified', 'sentence':str(original).strip()[:500],
|
| 4438 |
+
'reason':f'No verified candidate support; best overlap={overlap}, coverage={coverage:.3f}.',
|
| 4439 |
+
})
|
| 4440 |
+
self._append_citation_audit(section_name, 'flag_unverified_claim_for_section_retry', original, revised, f'best overlap={overlap}, coverage={coverage:.3f}')
|
| 4441 |
+
rebuilt.append(revised)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4442 |
cursor = end
|
| 4443 |
rebuilt.append(str(text)[cursor:])
|
| 4444 |
recovered = ''.join(rebuilt)
|
|
|
|
| 4521 |
return revised
|
| 4522 |
|
| 4523 |
def audit_and_resolve_citations(self, text, section_name='', quarantine=False):
|
| 4524 |
+
self._last_citation_semantic_violations = []
|
| 4525 |
value = str(text or '')
|
| 4526 |
if quarantine:
|
| 4527 |
return self.resolve_citations(value, quarantine=True)
|
|
|
|
| 4566 |
)
|
| 4567 |
return self.resolve_citations(value, quarantine=False)
|
| 4568 |
|
| 4569 |
+
@staticmethod
|
| 4570 |
+
def inspect_authorial_ai_meta(text):
|
| 4571 |
+
"""Return typed high-confidence authorial AI-meta findings without editing text."""
|
| 4572 |
+
findings = find_authorial_ai_meta_spans(str(text or ""))
|
| 4573 |
+
output = []
|
| 4574 |
+
for finding in findings:
|
| 4575 |
+
output.append({
|
| 4576 |
+
"type":"authorial_ai_meta_trace",
|
| 4577 |
+
"code":str(finding.get("code") or finding.get("type") or "AI_META"),
|
| 4578 |
+
"preview":str(finding.get("text") or finding.get("match") or "")[:180],
|
| 4579 |
+
"start":int(finding.get("start") or 0),
|
| 4580 |
+
"end":int(finding.get("end") or 0),
|
| 4581 |
+
})
|
| 4582 |
+
return output
|
| 4583 |
+
|
| 4584 |
@staticmethod
|
| 4585 |
def _remove_ai_meta_traces(text):
|
| 4586 |
"""Bounded deterministic repair for high-confidence authorial meta only.
|
|
|
|
| 4714 |
return value
|
| 4715 |
return revised.strip()
|
| 4716 |
|
| 4717 |
+
def critique_section(self, text, *, section_id, section_name, mode, evidence_contract=''):
|
| 4718 |
+
"""Independent top-journal critic that returns feedback only; never rewrites prose."""
|
| 4719 |
value = str(text or '').strip()
|
| 4720 |
mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
|
| 4721 |
critical_sections = ({'introduction', 'theory', 'discussion'} if mode == 'amj' else {'introduction', 'theoretical_framing', 'findings', 'discussion'})
|
| 4722 |
if str(section_id or '') not in critical_sections or len(value.split()) < 120:
|
| 4723 |
+
return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
|
| 4724 |
dimensions = APP_CONFIG.get('top_journal_critic_profiles', {}).get(mode, [])
|
| 4725 |
profile = APP_CONFIG.get('section_reasoning_profiles', {}).get(mode, {}).get(str(section_id or ''), '')
|
| 4726 |
prompt = f"""Assess ONLY this manuscript unit as an independent top-journal critic. Return valid JSON only:
|
|
|
|
| 4736 |
)
|
| 4737 |
parsed = self._extract_json_from_text(raw)
|
| 4738 |
if not isinstance(parsed, dict) or parsed.get('pass') is not False:
|
| 4739 |
+
return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
|
| 4740 |
+
issue = re.sub(r'\s+', ' ', str(parsed.get('critical_issue') or '')).strip()
|
| 4741 |
+
instruction = re.sub(r'\s+', ' ', str(parsed.get('repair_instruction') or issue)).strip()
|
| 4742 |
if not instruction:
|
| 4743 |
+
return {'pass': True, 'critical_issue': '', 'repair_instruction': ''}
|
| 4744 |
+
return {'pass': False, 'critical_issue': issue[:800], 'repair_instruction': instruction[:1200]}
|
| 4745 |
+
|
| 4746 |
+
def critique_and_repair_section(self, text, *, section_id, section_name, mode, evidence_contract=''):
|
| 4747 |
+
"""Legacy compatibility wrapper. Active S0-R1 path uses critique_section().
|
| 4748 |
+
|
| 4749 |
+
The wrapper intentionally preserves manuscript bytes and only records the
|
| 4750 |
+
critic verdict. It no longer owns a second writing-model rewrite.
|
| 4751 |
+
"""
|
| 4752 |
+
value = str(text or '').strip()
|
| 4753 |
+
verdict = self.critique_section(
|
| 4754 |
+
value, section_id=section_id, section_name=section_name, mode=mode,
|
| 4755 |
+
evidence_contract=evidence_contract,
|
| 4756 |
+
)
|
| 4757 |
+
if verdict.get('pass') is False:
|
| 4758 |
+
logging.warning(
|
| 4759 |
+
'Top-journal critic material issue (no direct rewrite): section=%s issue=%s',
|
| 4760 |
+
section_name, str(verdict.get('repair_instruction') or verdict.get('critical_issue') or '')[:180],
|
| 4761 |
+
)
|
| 4762 |
+
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4763 |
|
| 4764 |
def _abstract_manuscript_context(self, manuscript, max_tokens=18000):
|
| 4765 |
value = str(manuscript or '').strip()
|
core/quantitative_engine.py
CHANGED
|
@@ -328,7 +328,13 @@ Keywords: {keywords}"""
|
|
| 328 |
if char == ".":
|
| 329 |
prev = value[index - 1] if index > 0 else ""
|
| 330 |
nxt = value[index + 1] if index + 1 < len(value) else ""
|
| 331 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
prefix = value[max(0, index - 12): index + 1].lower()
|
| 333 |
abbreviation = any(prefix.endswith(item) for item in abbreviations)
|
| 334 |
boundary = not decimal and not abbreviation
|
|
@@ -487,6 +493,136 @@ Keywords: {keywords}"""
|
|
| 487 |
] if strict else [],
|
| 488 |
}
|
| 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."""
|
|
@@ -656,9 +792,9 @@ Keywords: {keywords}"""
|
|
| 656 |
|
| 657 |
@classmethod
|
| 658 |
def validate_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 659 |
-
|
| 660 |
-
if
|
| 661 |
-
raise RuntimeError(f"
|
| 662 |
return True
|
| 663 |
|
| 664 |
def resolve_empirical_data(
|
|
|
|
| 328 |
if char == ".":
|
| 329 |
prev = value[index - 1] if index > 0 else ""
|
| 330 |
nxt = value[index + 1] if index + 1 < len(value) else ""
|
| 331 |
+
# Keep both ordinary decimals (0.31) and leading-decimal
|
| 332 |
+
# statistical notation (p < .05) inside one sentence.
|
| 333 |
+
leading_decimal = bool(
|
| 334 |
+
nxt.isdigit()
|
| 335 |
+
and re.search(r"(?:[<>=≤≥]|\b(?:p|r|β))\s*$", value[max(0, index - 12):index], flags=re.I)
|
| 336 |
+
)
|
| 337 |
+
decimal = (prev.isdigit() and nxt.isdigit()) or leading_decimal
|
| 338 |
prefix = value[max(0, index - 12): index + 1].lower()
|
| 339 |
abbreviation = any(prefix.endswith(item) for item in abbreviations)
|
| 340 |
boundary = not decimal and not abbreviation
|
|
|
|
| 493 |
] if strict else [],
|
| 494 |
}
|
| 495 |
|
| 496 |
+
@classmethod
|
| 497 |
+
def inspect_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 498 |
+
"""Pure AMJ quantitative Evidence Ledger inspection; never rewrites prose."""
|
| 499 |
+
value = str(text or "")
|
| 500 |
+
meta = dict(metadata or {})
|
| 501 |
+
ledger = dict(meta.get("quantitative_evidence_ledger") or {})
|
| 502 |
+
if not ledger:
|
| 503 |
+
ledger = cls.build_quantitative_evidence_ledger(meta)
|
| 504 |
+
sample_size = ledger.get("sample_size")
|
| 505 |
+
scale_points = ledger.get("scale_points")
|
| 506 |
+
strict = bool(ledger.get("strict"))
|
| 507 |
+
constructs = dict(ledger.get("constructs") or {})
|
| 508 |
+
results = dict(ledger.get("association_results") or {})
|
| 509 |
+
uploaded = dict(ledger.get("uploaded_metric_anchors") or {})
|
| 510 |
+
known_constructs = [str(v) for v in constructs.values() if str(v)]
|
| 511 |
+
construct_folded = [c.casefold() for c in known_constructs]
|
| 512 |
+
forbidden = [str(x) for x in ledger.get("forbidden_unowned_methods") or []]
|
| 513 |
+
role_patterns = {
|
| 514 |
+
"predictor": r"(?:predictor|independent\s+variable|predictor\s+construct)",
|
| 515 |
+
"mediator": r"(?:mediator|mediating\s+variable|mediating\s+construct)",
|
| 516 |
+
"outcome": r"(?:outcome|dependent\s+variable|outcome\s+construct)",
|
| 517 |
+
}
|
| 518 |
+
causal_patterns = (
|
| 519 |
+
r"(?i)\bhad\s+a\s+causal\s+effect\s+on\b",
|
| 520 |
+
r"(?i)\bexerted\s+a\s+causal\s+effect\s+on\b",
|
| 521 |
+
r"(?i)\bcausal\s+effect\s+on\b",
|
| 522 |
+
r"(?i)\b(?:an|a|the)\s+causal\s+effect\b",
|
| 523 |
+
r"(?i)\bcausal\s+effects\b",
|
| 524 |
+
r"(?i)\bcausal\s+effect\b",
|
| 525 |
+
r"(?i)\bcausal\s+mediation\b",
|
| 526 |
+
)
|
| 527 |
+
violations = []
|
| 528 |
+
|
| 529 |
+
def add_metric_violations(sentence, pattern, expected, *, dtype="float", label="metric"):
|
| 530 |
+
if expected is None:
|
| 531 |
+
return
|
| 532 |
+
regex = re.compile(pattern, re.I)
|
| 533 |
+
for match in regex.finditer(sentence):
|
| 534 |
+
observed = match.group(2)
|
| 535 |
+
try:
|
| 536 |
+
same = int(float(observed)) == int(expected) if dtype == "int" else abs(float(observed) - float(expected)) < 0.0006
|
| 537 |
+
except Exception:
|
| 538 |
+
same = False
|
| 539 |
+
if not same:
|
| 540 |
+
expected_text = str(int(expected)) if dtype == "int" else f"{float(expected):.3f}".rstrip("0").rstrip(".")
|
| 541 |
+
violations.append({
|
| 542 |
+
"type":"quantitative_metric_mismatch", "metric":label,
|
| 543 |
+
"observed":observed, "expected":expected_text,
|
| 544 |
+
"preview":str(sentence).strip()[:180],
|
| 545 |
+
})
|
| 546 |
+
|
| 547 |
+
for _start, _end, sentence in cls._sentence_spans(value):
|
| 548 |
+
ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
|
| 549 |
+
if ownership not in {"study", "result"}:
|
| 550 |
+
continue
|
| 551 |
+
if sample_size:
|
| 552 |
+
for match in re.finditer(r"(?i)(\b(?:sample(?:\s+size)?(?:\s+of)?|N)\s*(?:=|:)?\s*)(\d{2,6})\b", sentence):
|
| 553 |
+
observed = int(match.group(2))
|
| 554 |
+
if observed != int(sample_size):
|
| 555 |
+
violations.append({"type":"sample_size_mismatch","observed":observed,"expected":int(sample_size),"preview":sentence.strip()[:180]})
|
| 556 |
+
for match in re.finditer(r"(?i)\b(\d{2,6})\s+(respondents|participants|observations|cases)\b", sentence):
|
| 557 |
+
observed = int(match.group(1))
|
| 558 |
+
if observed != int(sample_size):
|
| 559 |
+
violations.append({"type":"sample_count_mismatch","observed":observed,"expected":int(sample_size),"preview":sentence.strip()[:180]})
|
| 560 |
+
if scale_points:
|
| 561 |
+
for match in re.finditer(r"(?i)\b([2-9]|1[0-1])[- ]point\s+(Likert(?:-type)?\s+scale)", sentence):
|
| 562 |
+
observed = int(match.group(1))
|
| 563 |
+
if observed != int(scale_points):
|
| 564 |
+
violations.append({"type":"scale_points_mismatch","observed":observed,"expected":int(scale_points),"preview":sentence.strip()[:180]})
|
| 565 |
+
for role, role_pattern in role_patterns.items():
|
| 566 |
+
expected = str(constructs.get(role) or "")
|
| 567 |
+
if not expected:
|
| 568 |
+
continue
|
| 569 |
+
for candidate in known_constructs:
|
| 570 |
+
if candidate.casefold() == expected.casefold():
|
| 571 |
+
continue
|
| 572 |
+
pattern = re.compile(rf"(?i)({role_pattern}\s*(?:was|is|:|=)?\s*){re.escape(candidate)}")
|
| 573 |
+
if pattern.search(sentence):
|
| 574 |
+
violations.append({"type":"construct_role_mismatch","role":role,"observed":candidate,"expected":expected,"preview":sentence.strip()[:180]})
|
| 575 |
+
if strict:
|
| 576 |
+
for pattern in causal_patterns:
|
| 577 |
+
if re.search(pattern, sentence):
|
| 578 |
+
violations.append({"type":"causal_language_violation","preview":sentence.strip()[:180]})
|
| 579 |
+
break
|
| 580 |
+
if (any(c in sentence.casefold() for c in construct_folded) if construct_folded else False) and re.search(r"(?i)\bp\s*(?:=|<|>|≤|≥)\s*(?:0?\.\d+|\d+(?:\.\d+)?)", sentence):
|
| 581 |
+
violations.append({"type":"unfrozen_p_value","preview":sentence.strip()[:180]})
|
| 582 |
+
if ownership == "study":
|
| 583 |
+
bad = next((term for term in forbidden if re.search(rf"(?i)(?<![A-Za-z]){re.escape(term)}(?![A-Za-z])", sentence)), "")
|
| 584 |
+
if bad:
|
| 585 |
+
violations.append({"type":"unsupported_quantitative_method","method":bad,"preview":sentence.strip()[:180]})
|
| 586 |
+
|
| 587 |
+
vif_expected = results.get("predictor_mediator_vif") if strict else uploaded.get("VIF")
|
| 588 |
+
boot_expected = results.get("bootstrap_iterations") if strict else uploaded.get("bootstrap_iterations")
|
| 589 |
+
add_metric_violations(sentence, r"(\bVIF\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", vif_expected, label="VIF")
|
| 590 |
+
add_metric_violations(sentence, r"(\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*)(\d{2,6})", boot_expected, dtype="int", label="bootstrap_iterations")
|
| 591 |
+
if strict and results:
|
| 592 |
+
for role, key in (("mediator", "r2_mediator"), ("outcome", "r2_outcome")):
|
| 593 |
+
construct = str(constructs.get(role) or "")
|
| 594 |
+
if construct and results.get(key) is not None:
|
| 595 |
+
add_metric_violations(sentence, rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", results.get(key), label=key)
|
| 596 |
+
lower = sentence.casefold()
|
| 597 |
+
selected = None
|
| 598 |
+
specs = [
|
| 599 |
+
("indirect", results.get("indirect"), [constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")]),
|
| 600 |
+
("direct", results.get("direct"), [constructs.get("predictor"), constructs.get("outcome")]),
|
| 601 |
+
("a", results.get("a"), [constructs.get("predictor"), constructs.get("mediator")]),
|
| 602 |
+
("b", results.get("b"), [constructs.get("mediator"), constructs.get("outcome")]),
|
| 603 |
+
]
|
| 604 |
+
if "indirect" in lower and all(str(x or "").casefold() in lower for x in specs[0][2] if x):
|
| 605 |
+
selected = specs[0]
|
| 606 |
+
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:
|
| 607 |
+
selected = specs[1]
|
| 608 |
+
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:
|
| 609 |
+
selected = specs[3]
|
| 610 |
+
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:
|
| 611 |
+
selected = specs[2]
|
| 612 |
+
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:
|
| 613 |
+
selected = specs[3]
|
| 614 |
+
if selected and selected[1] is not None:
|
| 615 |
+
add_metric_violations(sentence, r"((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", selected[1], label=selected[0])
|
| 616 |
+
elif not strict:
|
| 617 |
+
for label, expected in dict(uploaded.get("r2_by_label") or {}).items():
|
| 618 |
+
if label and label in sentence.casefold():
|
| 619 |
+
add_metric_violations(sentence, rf"(R(?:²|\^2)\s*(?:for|of)\s*{re.escape(label)}\s*(?:=|:)\s*)([-+]?\d+(?:\.\d+)?)", expected, label=f"uploaded_R2:{label}")
|
| 620 |
+
for path, expected in dict(uploaded.get("beta_by_path") or {}).items():
|
| 621 |
+
left, _, right = path.partition(" -> ")
|
| 622 |
+
if left and right and left in sentence.casefold() and right in sentence.casefold():
|
| 623 |
+
add_metric_violations(sentence, r"((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", expected, label=f"uploaded_beta:{path}")
|
| 624 |
+
return violations
|
| 625 |
+
|
| 626 |
@classmethod
|
| 627 |
def repair_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 628 |
"""Repair deterministic AMJ drift only in study/result-owned prose."""
|
|
|
|
| 792 |
|
| 793 |
@classmethod
|
| 794 |
def validate_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
|
| 795 |
+
violations = cls.inspect_quantitative_study_fact_contract(text, metadata, section_name=section_name)
|
| 796 |
+
if violations:
|
| 797 |
+
raise RuntimeError(f"quantitative_study_fact_contract_invalid:{section_name}:{violations[:3]}")
|
| 798 |
return True
|
| 799 |
|
| 800 |
def resolve_empirical_data(
|