Spaces:
Running
Running
S0-R1-C5 Dual-Engine Final Quality Contracts
#78
by GuXiaopeng - opened
- core/generation_runner.py +403 -7
- core/pipeline_agent.py +101 -0
- core/production_guard.py +90 -0
- core/quantitative_engine.py +111 -0
core/generation_runner.py
CHANGED
|
@@ -36,6 +36,7 @@ from utils.file_parser import extract_text_from_files
|
|
| 36 |
ProgressCallback = Callable[..., None]
|
| 37 |
|
| 38 |
CITATION_SEMANTIC_SURVIVAL_RATIO = 0.78
|
|
|
|
| 39 |
CITATION_ROLE_RULES = {
|
| 40 |
"introduction": (3, 180),
|
| 41 |
"theory": (3, 180),
|
|
@@ -976,6 +977,220 @@ class GenerationRunner:
|
|
| 976 |
tags.add(f'[{token}]')
|
| 977 |
return tags
|
| 978 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
@staticmethod
|
| 980 |
def _snapshot_section_citation_state(agent, citation_plan: dict, unit_name: str) -> dict:
|
| 981 |
"""Snapshot only Section-owned citation state so a rejected attempt cannot leak forward."""
|
|
@@ -1007,7 +1222,7 @@ class GenerationRunner:
|
|
| 1007 |
def _section_validation_bundle(
|
| 1008 |
cls, mode: str, agent, quant_engine, text: str, data_metadata: dict, *,
|
| 1009 |
section_id: str, section_name: str, evidence_contract: str = "", include_critic: bool = True,
|
| 1010 |
-
citation_violations=None,
|
| 1011 |
) -> list[dict]:
|
| 1012 |
"""Collect typed S0-R1 validation feedback without mutating manuscript prose."""
|
| 1013 |
value = str(text or "")
|
|
@@ -1027,6 +1242,12 @@ class GenerationRunner:
|
|
| 1027 |
violations.extend(cls._study_fact_violations(
|
| 1028 |
mode, agent, quant_engine, value, data_metadata, section_name=section_name
|
| 1029 |
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1030 |
violations.extend(copy.deepcopy(list(citation_violations or [])))
|
| 1031 |
return violations
|
| 1032 |
|
|
@@ -1952,8 +2173,10 @@ class GenerationRunner:
|
|
| 1952 |
"external_claims": external_claims,
|
| 1953 |
"planning_minimum": int(planned_minimum),
|
| 1954 |
})
|
|
|
|
| 1955 |
return {
|
| 1956 |
-
"status":
|
|
|
|
| 1957 |
"early_risk_detected": bool(early_risk),
|
| 1958 |
"final_reference_count": max(0, int(final_reference_count or 0)),
|
| 1959 |
"sections": sections,
|
|
@@ -1964,6 +2187,55 @@ class GenerationRunner:
|
|
| 1964 |
"events": list(events or []),
|
| 1965 |
}
|
| 1966 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1967 |
@staticmethod
|
| 1968 |
def _paragraph_token_set(value):
|
| 1969 |
text = re.sub(r'\[CITE-\d+\]', ' ', str(value or ''), flags=re.I)
|
|
@@ -2147,6 +2419,35 @@ class GenerationRunner:
|
|
| 2147 |
)
|
| 2148 |
self._checkpoint("quantitative_evidence_ledger_upgraded", state, 6)
|
| 2149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2150 |
self._qualitative_quote_sources = (
|
| 2151 |
self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
|
| 2152 |
)
|
|
@@ -2643,6 +2944,27 @@ class GenerationRunner:
|
|
| 2643 |
state['canonical_concept_ledger'] = canonical_concept_ledger
|
| 2644 |
self._checkpoint('canonical_concept_ledger_ready', state, 15)
|
| 2645 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2646 |
current_entity_ledger_version = str(
|
| 2647 |
getattr(translation_agent, 'PROTECTED_ENTITY_LEDGER_VERSION', 'protected-entity-ledger-v1')
|
| 2648 |
or 'protected-entity-ledger-v1'
|
|
@@ -2807,6 +3129,7 @@ class GenerationRunner:
|
|
| 2807 |
quote_invocation_plan, adaptive_structure, data_metadata
|
| 2808 |
)
|
| 2809 |
|
|
|
|
| 2810 |
total_steps = len(adaptive_structure)
|
| 2811 |
for sec in adaptive_structure:
|
| 2812 |
display_name = sec["display_name"]
|
|
@@ -2890,10 +3213,16 @@ class GenerationRunner:
|
|
| 2890 |
" Use level-2 or level-3 headings only when they clarify the argument; "
|
| 2891 |
"never use level-4 headings or numbered headings."
|
| 2892 |
)
|
| 2893 |
-
|
| 2894 |
-
|
| 2895 |
-
|
| 2896 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2897 |
concepts = ", ".join(sec.get("concepts") or [])
|
| 2898 |
theme_evidence = [item for item in (sec.get("theme_evidence") or []) if isinstance(item, dict)]
|
| 2899 |
evidence_profiles = []
|
|
@@ -2973,6 +3302,8 @@ Planning length: approximately {int(adjusted_target * min_ratio)}-{int(adjusted_
|
|
| 2973 |
{heading_directive}
|
| 2974 |
{citation_rule}
|
| 2975 |
{table_rule}
|
|
|
|
|
|
|
| 2976 |
{str(sec.get('required_content') or '')}
|
| 2977 |
{qualitative_rule}
|
| 2978 |
{anti_repeat}
|
|
@@ -3117,7 +3448,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3117 |
section_id=section_id, section_name=display_name,
|
| 3118 |
evidence_contract=evidence_packet,
|
| 3119 |
include_critic=not deterministic_appendix,
|
| 3120 |
-
citation_violations=citation_semantic_rows,
|
| 3121 |
)
|
| 3122 |
non_citation_count = max(
|
| 3123 |
0, len(phase1_validation_rows) - len(citation_semantic_rows)
|
|
@@ -3219,6 +3550,17 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3219 |
quote_invocation_plan=quote_invocation_plan,
|
| 3220 |
)
|
| 3221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3222 |
# S0-R1 phase 2: only local/idempotent structural normalizers may
|
| 3223 |
# mutate the candidate before the final pure validation bundle.
|
| 3224 |
en_resolved, enumeration_rows = agent.repair_and_validate_enumeration_contract(
|
|
@@ -3268,6 +3610,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3268 |
mode, agent, quant_engine, en_resolved, data_metadata,
|
| 3269 |
section_id=section_id, section_name=display_name,
|
| 3270 |
evidence_contract=evidence_packet, include_critic=False,
|
|
|
|
| 3271 |
)
|
| 3272 |
if post_validation_rows:
|
| 3273 |
if section_attempt < max_section_attempts:
|
|
@@ -3345,6 +3688,46 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3345 |
frozen_section = self._extract_top_level_section(full_draft_en, sec_title)
|
| 3346 |
if not frozen_section:
|
| 3347 |
raise RuntimeError(f"english_section_seal_source_missing:{sec_title}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3348 |
section_integrity_seals[section_key] = self._build_section_integrity_seal(
|
| 3349 |
section_key, sec_title, frozen_section
|
| 3350 |
)
|
|
@@ -3424,6 +3807,17 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3424 |
agent.validate_enumeration_contract(
|
| 3425 |
sealed_section, section_name=f"Final sealed section: {section_title}"
|
| 3426 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3427 |
# Validate the completed body while the Working Abstract is still in
|
| 3428 |
# place. Final front matter must not be generated until all body
|
| 3429 |
# sections, citation cleanup, and any Discussion rebalance are frozen.
|
|
@@ -3762,6 +4156,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3762 |
if str(state.get("evidence_status") or "") == "limited":
|
| 3763 |
result.update({
|
| 3764 |
"quality_status": "evidence_limited",
|
|
|
|
| 3765 |
"safe_error_code": "W_EVIDENCE_COVERAGE_LIMITED",
|
| 3766 |
"safe_error_detail": self._evidence_warning_detail(),
|
| 3767 |
})
|
|
@@ -3807,6 +4202,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3807 |
if str(state.get("evidence_status") or "") == "limited":
|
| 3808 |
result.update({
|
| 3809 |
"quality_status": "evidence_limited",
|
|
|
|
| 3810 |
"safe_error_code": "W_EVIDENCE_COVERAGE_LIMITED",
|
| 3811 |
"safe_error_detail": self._evidence_warning_detail(),
|
| 3812 |
})
|
|
|
|
| 36 |
ProgressCallback = Callable[..., None]
|
| 37 |
|
| 38 |
CITATION_SEMANTIC_SURVIVAL_RATIO = 0.78
|
| 39 |
+
EVIDENCE_COMPLETION_MAX_PER_TOP_LEVEL = 4
|
| 40 |
CITATION_ROLE_RULES = {
|
| 41 |
"introduction": (3, 180),
|
| 42 |
"theory": (3, 180),
|
|
|
|
| 977 |
tags.add(f'[{token}]')
|
| 978 |
return tags
|
| 979 |
|
| 980 |
+
@staticmethod
|
| 981 |
+
def _author_facing_internal_workflow_violations(text: str) -> list[dict]:
|
| 982 |
+
"""Detect SmartPaper control vocabulary that must stay out of author prose.
|
| 983 |
+
|
| 984 |
+
The check is intentionally phrase-scoped. A generic word such as
|
| 985 |
+
``frozen`` can be legitimate academic content, but phrases naming the
|
| 986 |
+
private evidence/state machinery are never reader-facing manuscript
|
| 987 |
+
language.
|
| 988 |
+
"""
|
| 989 |
+
value = str(text or "")
|
| 990 |
+
patterns = [
|
| 991 |
+
r"\bfrozen\s+(?:research\s+)?evidence\b",
|
| 992 |
+
r"\bfrozen\s+evidence\s+packet\b",
|
| 993 |
+
r"\bevidence\s+packet\b",
|
| 994 |
+
r"\bfrozen\s+(?:theme\s+summary|thematic\s+structure|statistical\s+evidence|measurement\s+design|association\s+model|bootstrap\s+and\s+collinearity\s+checks|task-level\s+concepts?)\b",
|
| 995 |
+
r"\b(?:study|result)-owned\s+fact\b",
|
| 996 |
+
r"\bevidence\s+ledger\b",
|
| 997 |
+
]
|
| 998 |
+
rows = []
|
| 999 |
+
for pattern in patterns:
|
| 1000 |
+
for match in re.finditer(pattern, value, flags=re.I):
|
| 1001 |
+
start = max(0, match.start() - 80)
|
| 1002 |
+
end = min(len(value), match.end() + 120)
|
| 1003 |
+
preview = re.sub(r"\s+", " ", value[start:end]).strip()
|
| 1004 |
+
rows.append({
|
| 1005 |
+
"type": "author_facing_internal_workflow_language",
|
| 1006 |
+
"preview": preview[:320],
|
| 1007 |
+
"reason": "Private SmartPaper evidence/state vocabulary surfaced in reader-facing manuscript prose.",
|
| 1008 |
+
"repair_instruction": (
|
| 1009 |
+
"Rewrite the sentence as ordinary academic prose describing the study, evidence, method, or limitation directly. "
|
| 1010 |
+
"Do not mention frozen evidence, evidence packets, ledgers, study-owned/result-owned facts, or other orchestration labels."
|
| 1011 |
+
),
|
| 1012 |
+
})
|
| 1013 |
+
if len(rows) >= 6:
|
| 1014 |
+
return rows
|
| 1015 |
+
return rows
|
| 1016 |
+
|
| 1017 |
+
@staticmethod
|
| 1018 |
+
def _hypothesis_contract_directive(ledger: dict) -> str:
|
| 1019 |
+
record = dict(ledger or {})
|
| 1020 |
+
items = [item for item in (record.get("items") or []) if isinstance(item, dict)]
|
| 1021 |
+
if not items:
|
| 1022 |
+
return ""
|
| 1023 |
+
lines = []
|
| 1024 |
+
for item in items:
|
| 1025 |
+
hid = str(item.get("hypothesis_id") or "").strip()
|
| 1026 |
+
predictor = str(item.get("predictor") or "").strip()
|
| 1027 |
+
outcome = str(item.get("outcome") or "").strip()
|
| 1028 |
+
if hid and predictor and outcome:
|
| 1029 |
+
lines.append(f"{hid}: {predictor} is associated with {outcome}.")
|
| 1030 |
+
if not lines:
|
| 1031 |
+
return ""
|
| 1032 |
+
indirect = dict(record.get("indirect_analysis") or {})
|
| 1033 |
+
indirect_line = ""
|
| 1034 |
+
if indirect:
|
| 1035 |
+
p = str(indirect.get("predictor") or "").strip()
|
| 1036 |
+
m = str(indirect.get("mediator") or "").strip()
|
| 1037 |
+
o = str(indirect.get("outcome") or "").strip()
|
| 1038 |
+
if p and m and o:
|
| 1039 |
+
indirect_line = (
|
| 1040 |
+
f"The estimated indirect association {p} -> {m} -> {o} is an analysis target, not a separately numbered hypothesis."
|
| 1041 |
+
)
|
| 1042 |
+
rendered = " ".join(lines + ([indirect_line] if indirect_line else []))
|
| 1043 |
+
return (
|
| 1044 |
+
"IMMUTABLE HYPOTHESIS CONTRACT: " + rendered
|
| 1045 |
+
+ " Use H1/H2/H3-style identifiers exactly as listed whenever hypotheses are defined or referenced. "
|
| 1046 |
+
"Do not renumber, reorder, create an additional hypothesis, replace an H-label with 'first/second/third hypothesis', "
|
| 1047 |
+
"or change the construct pairing to fit observed results. The contract is result-independent."
|
| 1048 |
+
)
|
| 1049 |
+
|
| 1050 |
+
@classmethod
|
| 1051 |
+
def _hypothesis_contract_violations(
|
| 1052 |
+
cls, text: str, ledger: dict, *, section_name: str = "", require_all: bool = False
|
| 1053 |
+
) -> list[dict]:
|
| 1054 |
+
record = dict(ledger or {})
|
| 1055 |
+
items = [item for item in (record.get("items") or []) if isinstance(item, dict)]
|
| 1056 |
+
if not items:
|
| 1057 |
+
return []
|
| 1058 |
+
value = str(text or "")
|
| 1059 |
+
expected = {
|
| 1060 |
+
str(item.get("hypothesis_id") or "").upper(): item
|
| 1061 |
+
for item in items if str(item.get("hypothesis_id") or "").strip()
|
| 1062 |
+
}
|
| 1063 |
+
if not expected:
|
| 1064 |
+
return []
|
| 1065 |
+
rows = []
|
| 1066 |
+
observed = {f"H{n}" for n in re.findall(r"\bH(\d{1,2})\b", value, flags=re.I)}
|
| 1067 |
+
unknown = sorted(observed - set(expected))
|
| 1068 |
+
if unknown:
|
| 1069 |
+
rows.append({
|
| 1070 |
+
"type": "hypothesis_contract_unknown_id",
|
| 1071 |
+
"observed": unknown,
|
| 1072 |
+
"expected": sorted(expected),
|
| 1073 |
+
"reason": "The manuscript introduced a hypothesis identifier outside the frozen result-independent hypothesis ledger.",
|
| 1074 |
+
"repair_instruction": "Use only the frozen H-identifiers and keep the indirect association unnumbered unless the ledger explicitly numbers it.",
|
| 1075 |
+
})
|
| 1076 |
+
ordinal = re.search(r"\b(?:first|second|third|fourth|fifth|sixth)\s+hypothesis\b", value, flags=re.I)
|
| 1077 |
+
if ordinal:
|
| 1078 |
+
rows.append({
|
| 1079 |
+
"type": "hypothesis_contract_ordinal_alias",
|
| 1080 |
+
"preview": re.sub(r"\s+", " ", value[max(0, ordinal.start()-80):ordinal.end()+120]).strip()[:320],
|
| 1081 |
+
"reason": "Ordinal hypothesis aliases can silently reorder the canonical H1-Hn identity.",
|
| 1082 |
+
"repair_instruction": "Refer to hypotheses only by their frozen H1-Hn identifiers.",
|
| 1083 |
+
})
|
| 1084 |
+
definition_scope = bool(re.search(r"Hypothesis Development|Integrated Research Model", str(section_name or ""), flags=re.I))
|
| 1085 |
+
if definition_scope:
|
| 1086 |
+
all_constructs = {
|
| 1087 |
+
re.sub(r"\s+", " ", str(item.get(key) or "")).strip()
|
| 1088 |
+
for item in items for key in ("predictor", "outcome")
|
| 1089 |
+
if str(item.get(key) or "").strip()
|
| 1090 |
+
}
|
| 1091 |
+
for start, end, sentence in SSciPipelineAgent._sentence_spans(value):
|
| 1092 |
+
ids = list(dict.fromkeys(
|
| 1093 |
+
f"H{n}" for n in re.findall(r"\bH(\d{1,2})\b", sentence, flags=re.I)
|
| 1094 |
+
))
|
| 1095 |
+
# Collective references such as "H1-H3 are summarized below"
|
| 1096 |
+
# are not definitions and must not be mistaken for remapping.
|
| 1097 |
+
if len(ids) != 1:
|
| 1098 |
+
continue
|
| 1099 |
+
hid = ids[0]
|
| 1100 |
+
item = expected.get(hid)
|
| 1101 |
+
if not item:
|
| 1102 |
+
continue
|
| 1103 |
+
predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
|
| 1104 |
+
outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
|
| 1105 |
+
lower_sentence = re.sub(r"\s+", " ", sentence).casefold()
|
| 1106 |
+
mentioned_constructs = {
|
| 1107 |
+
term for term in all_constructs if term.casefold() in lower_sentence
|
| 1108 |
+
}
|
| 1109 |
+
# Validate pairing only when the sentence actually names one or
|
| 1110 |
+
# more constructs. A bare cross-reference ("H1 is summarized")
|
| 1111 |
+
# remains legal and cannot silently redefine the hypothesis.
|
| 1112 |
+
if mentioned_constructs and predictor and outcome and (
|
| 1113 |
+
predictor.casefold() not in lower_sentence or outcome.casefold() not in lower_sentence
|
| 1114 |
+
):
|
| 1115 |
+
rows.append({
|
| 1116 |
+
"type": "hypothesis_contract_pairing_mismatch",
|
| 1117 |
+
"id": hid,
|
| 1118 |
+
"sentence": re.sub(r"\s+", " ", sentence).strip()[:420],
|
| 1119 |
+
"expected": f"{predictor} -> {outcome}",
|
| 1120 |
+
"reason": "A canonical hypothesis label was attached to a different construct pairing.",
|
| 1121 |
+
"repair_instruction": f"Define {hid} only for {predictor} and {outcome}, using association language.",
|
| 1122 |
+
})
|
| 1123 |
+
if require_all:
|
| 1124 |
+
missing = sorted(set(expected) - observed)
|
| 1125 |
+
if missing:
|
| 1126 |
+
rows.append({
|
| 1127 |
+
"type": "hypothesis_contract_missing_id",
|
| 1128 |
+
"observed": sorted(observed),
|
| 1129 |
+
"expected": sorted(expected),
|
| 1130 |
+
"reason": "The completed hypothesis architecture omitted one or more frozen hypothesis identifiers.",
|
| 1131 |
+
"repair_instruction": "Preserve every canonical H1-Hn identity from theory through the integrated model and downstream reporting.",
|
| 1132 |
+
})
|
| 1133 |
+
return rows[:8]
|
| 1134 |
+
|
| 1135 |
+
@staticmethod
|
| 1136 |
+
def _strip_model_markdown_tables(text: str) -> str:
|
| 1137 |
+
"""Remove writer-created Markdown tables before deterministic AMJ table binding."""
|
| 1138 |
+
lines = str(text or "").splitlines()
|
| 1139 |
+
kept = []
|
| 1140 |
+
index = 0
|
| 1141 |
+
while index < len(lines):
|
| 1142 |
+
line = lines[index]
|
| 1143 |
+
if re.match(r"^\s*(?:#{1,6}\s*)?Table\s+\d+\s*[:.\-]", line, flags=re.I):
|
| 1144 |
+
index += 1
|
| 1145 |
+
while index < len(lines) and not lines[index].strip():
|
| 1146 |
+
index += 1
|
| 1147 |
+
if index < len(lines) and lines[index].lstrip().startswith("|"):
|
| 1148 |
+
while index < len(lines) and lines[index].lstrip().startswith("|"):
|
| 1149 |
+
index += 1
|
| 1150 |
+
if index < len(lines) and re.match(r"^\s*\*?Note\s*:", lines[index], flags=re.I):
|
| 1151 |
+
index += 1
|
| 1152 |
+
continue
|
| 1153 |
+
if line.lstrip().startswith("|"):
|
| 1154 |
+
start = index
|
| 1155 |
+
while index < len(lines) and lines[index].lstrip().startswith("|"):
|
| 1156 |
+
index += 1
|
| 1157 |
+
block = lines[start:index]
|
| 1158 |
+
if len(block) >= 2 and re.match(r"^\s*\|?\s*:?-{3,}", block[1]):
|
| 1159 |
+
continue
|
| 1160 |
+
kept.extend(block)
|
| 1161 |
+
continue
|
| 1162 |
+
kept.append(line)
|
| 1163 |
+
index += 1
|
| 1164 |
+
return re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip()
|
| 1165 |
+
|
| 1166 |
+
@classmethod
|
| 1167 |
+
def _apply_quantitative_table_contract(cls, text: str, data_metadata: dict, display_name: str) -> str:
|
| 1168 |
+
manifest = dict((data_metadata or {}).get("quantitative_table_manifest") or {})
|
| 1169 |
+
tables = [item for item in (manifest.get("tables") or []) if isinstance(item, dict)]
|
| 1170 |
+
if not tables:
|
| 1171 |
+
return str(text or "")
|
| 1172 |
+
value = cls._strip_model_markdown_tables(text)
|
| 1173 |
+
assigned = []
|
| 1174 |
+
for item in tables:
|
| 1175 |
+
target = str(item.get("target_subsection") or "").strip()
|
| 1176 |
+
if target and target.casefold() in str(display_name or "").casefold():
|
| 1177 |
+
assigned.append(item)
|
| 1178 |
+
if not assigned:
|
| 1179 |
+
return value
|
| 1180 |
+
blocks = []
|
| 1181 |
+
for item in sorted(assigned, key=lambda row: int(row.get("number") or 0)):
|
| 1182 |
+
number = int(item.get("number") or 0)
|
| 1183 |
+
title = str(item.get("title") or "").strip()
|
| 1184 |
+
markdown = str(item.get("markdown") or "").strip()
|
| 1185 |
+
note = str(item.get("note") or "").strip()
|
| 1186 |
+
if not number or not title or not markdown:
|
| 1187 |
+
raise RuntimeError(f"quantitative_table_manifest_invalid:{display_name}")
|
| 1188 |
+
block = f"Table {number}: {title}\n{markdown}"
|
| 1189 |
+
if note:
|
| 1190 |
+
block += f"\n\n*Note: {note}*"
|
| 1191 |
+
blocks.append(block)
|
| 1192 |
+
return (value.rstrip() + "\n\n" + "\n\n".join(blocks)).strip()
|
| 1193 |
+
|
| 1194 |
@staticmethod
|
| 1195 |
def _snapshot_section_citation_state(agent, citation_plan: dict, unit_name: str) -> dict:
|
| 1196 |
"""Snapshot only Section-owned citation state so a rejected attempt cannot leak forward."""
|
|
|
|
| 1222 |
def _section_validation_bundle(
|
| 1223 |
cls, mode: str, agent, quant_engine, text: str, data_metadata: dict, *,
|
| 1224 |
section_id: str, section_name: str, evidence_contract: str = "", include_critic: bool = True,
|
| 1225 |
+
citation_violations=None, hypothesis_ledger=None,
|
| 1226 |
) -> list[dict]:
|
| 1227 |
"""Collect typed S0-R1 validation feedback without mutating manuscript prose."""
|
| 1228 |
value = str(text or "")
|
|
|
|
| 1242 |
violations.extend(cls._study_fact_violations(
|
| 1243 |
mode, agent, quant_engine, value, data_metadata, section_name=section_name
|
| 1244 |
))
|
| 1245 |
+
violations.extend(cls._author_facing_internal_workflow_violations(value))
|
| 1246 |
+
if str(mode or "").lower() == "amj":
|
| 1247 |
+
violations.extend(cls._hypothesis_contract_violations(
|
| 1248 |
+
value, hypothesis_ledger or {}, section_name=section_name,
|
| 1249 |
+
require_all=bool(re.search(r"Integrated Research Model", str(section_name or ""), flags=re.I)),
|
| 1250 |
+
))
|
| 1251 |
violations.extend(copy.deepcopy(list(citation_violations or [])))
|
| 1252 |
return violations
|
| 1253 |
|
|
|
|
| 2173 |
"external_claims": external_claims,
|
| 2174 |
"planning_minimum": int(planned_minimum),
|
| 2175 |
})
|
| 2176 |
+
normalized_status = str(status or "adequate")
|
| 2177 |
return {
|
| 2178 |
+
"status": normalized_status,
|
| 2179 |
+
"submission_readiness": "review_required" if normalized_status == "limited" else "quality_gate_passed",
|
| 2180 |
"early_risk_detected": bool(early_risk),
|
| 2181 |
"final_reference_count": max(0, int(final_reference_count or 0)),
|
| 2182 |
"sections": sections,
|
|
|
|
| 2187 |
"events": list(events or []),
|
| 2188 |
}
|
| 2189 |
|
| 2190 |
+
def _bounded_top_level_evidence_completion(
|
| 2191 |
+
self, agent, *, topic: str, section_id: str, section_name: str, section_text: str,
|
| 2192 |
+
required: int, existing_tags, excluded_tags=None,
|
| 2193 |
+
) -> tuple[str, list[str]]:
|
| 2194 |
+
"""Give one completed top-level section a single bounded evidence top-up.
|
| 2195 |
+
|
| 2196 |
+
This is not a prose-generation path. It issues at most one contextual JIT
|
| 2197 |
+
retrieval and may attach only semantically validated citations to claims
|
| 2198 |
+
that already exist. If no directly supporting source is found, the prose
|
| 2199 |
+
remains unchanged and the manuscript later reports evidence-limited quality.
|
| 2200 |
+
"""
|
| 2201 |
+
value = str(section_text or "")
|
| 2202 |
+
current = {str(tag or "").strip() for tag in (existing_tags or []) if str(tag or "").strip()}
|
| 2203 |
+
needed = max(0, int(required or 0) - len(current))
|
| 2204 |
+
needed = min(EVIDENCE_COMPLETION_MAX_PER_TOP_LEVEL, needed)
|
| 2205 |
+
if needed <= 0 or not value.strip():
|
| 2206 |
+
return value, []
|
| 2207 |
+
before = set(getattr(agent, "used_citations", set()) or set())
|
| 2208 |
+
items = agent.fetch_contextual_jit_recovery_items(
|
| 2209 |
+
topic=topic,
|
| 2210 |
+
section_name=section_name,
|
| 2211 |
+
section_id=section_id,
|
| 2212 |
+
section_context=value,
|
| 2213 |
+
needed=needed,
|
| 2214 |
+
excluded_tags=(before | set(excluded_tags or set())),
|
| 2215 |
+
)
|
| 2216 |
+
if not items:
|
| 2217 |
+
logging.info(
|
| 2218 |
+
"Bounded evidence completion found no eligible source | section=%s needed=%s",
|
| 2219 |
+
section_name, needed,
|
| 2220 |
+
)
|
| 2221 |
+
return value, []
|
| 2222 |
+
recovered = agent.recover_citations_on_existing_claims(
|
| 2223 |
+
value, items, section_name=section_name, section_id=section_id, needed=needed
|
| 2224 |
+
)
|
| 2225 |
+
after = set(getattr(agent, "used_citations", set()) or set())
|
| 2226 |
+
added = sorted(after - before, key=agent._citation_tag_number)
|
| 2227 |
+
if recovered == value or not added:
|
| 2228 |
+
logging.info(
|
| 2229 |
+
"Bounded evidence completion made no validated attachment | section=%s candidates=%s",
|
| 2230 |
+
section_name, len(items),
|
| 2231 |
+
)
|
| 2232 |
+
return value, []
|
| 2233 |
+
logging.info(
|
| 2234 |
+
"Bounded evidence completion accepted | section=%s added=%s required=%s",
|
| 2235 |
+
section_name, len(added), required,
|
| 2236 |
+
)
|
| 2237 |
+
return recovered, added
|
| 2238 |
+
|
| 2239 |
@staticmethod
|
| 2240 |
def _paragraph_token_set(value):
|
| 2241 |
text = re.sub(r'\[CITE-\d+\]', ' ', str(value or ''), flags=re.I)
|
|
|
|
| 2419 |
)
|
| 2420 |
self._checkpoint("quantitative_evidence_ledger_upgraded", state, 6)
|
| 2421 |
|
| 2422 |
+
if str(data_metadata.get("source_type") or "") == "system_generated":
|
| 2423 |
+
table_manifest = dict(data_metadata.get("quantitative_table_manifest") or {})
|
| 2424 |
+
expected_table_version = str(getattr(quant_engine, "QUANTITATIVE_TABLE_MANIFEST_VERSION", ""))
|
| 2425 |
+
if table_manifest.get("version") != expected_table_version:
|
| 2426 |
+
rebuilt_tables = quant_engine.build_table_manifest_from_evidence_markdown(user_data_md)
|
| 2427 |
+
if not rebuilt_tables:
|
| 2428 |
+
raise RuntimeError("quantitative_table_manifest_rebuild_failed")
|
| 2429 |
+
data_metadata["quantitative_table_manifest"] = rebuilt_tables
|
| 2430 |
+
logging.warning(
|
| 2431 |
+
"Quantitative table manifest contract advanced; rebuilt locally version=%s",
|
| 2432 |
+
expected_table_version,
|
| 2433 |
+
)
|
| 2434 |
+
|
| 2435 |
+
hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {})
|
| 2436 |
+
expected_hypothesis_version = str(getattr(quant_engine, "HYPOTHESIS_LEDGER_VERSION", ""))
|
| 2437 |
+
if hypothesis_ledger.get("version") != expected_hypothesis_version:
|
| 2438 |
+
constructs = dict(data_metadata.get("constructs") or {})
|
| 2439 |
+
rebuilt_hypotheses = quant_engine.build_hypothesis_ledger([
|
| 2440 |
+
constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")
|
| 2441 |
+
])
|
| 2442 |
+
if not rebuilt_hypotheses:
|
| 2443 |
+
raise RuntimeError("quantitative_hypothesis_ledger_rebuild_failed")
|
| 2444 |
+
data_metadata["hypothesis_ledger"] = rebuilt_hypotheses
|
| 2445 |
+
logging.warning(
|
| 2446 |
+
"Quantitative hypothesis ledger contract advanced; rebuilt locally version=%s",
|
| 2447 |
+
expected_hypothesis_version,
|
| 2448 |
+
)
|
| 2449 |
+
state["data_metadata"] = data_metadata
|
| 2450 |
+
|
| 2451 |
self._qualitative_quote_sources = (
|
| 2452 |
self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
|
| 2453 |
)
|
|
|
|
| 2944 |
state['canonical_concept_ledger'] = canonical_concept_ledger
|
| 2945 |
self._checkpoint('canonical_concept_ledger_ready', state, 15)
|
| 2946 |
|
| 2947 |
+
glossary_quality_version = str(
|
| 2948 |
+
getattr(translation_agent, 'CANONICAL_GLOSSARY_QUALITY_VERSION', 'canonical-glossary-quality-v1')
|
| 2949 |
+
or 'canonical-glossary-quality-v1'
|
| 2950 |
+
)
|
| 2951 |
+
if state.get('canonical_glossary_quality_version') != glossary_quality_version:
|
| 2952 |
+
reviewed_glossary = translation_agent.validate_and_refine_canonical_concept_glossary(
|
| 2953 |
+
canonical_concept_ledger, target_lang, translation_glossary
|
| 2954 |
+
)
|
| 2955 |
+
if reviewed_glossary != translation_glossary:
|
| 2956 |
+
translation_glossary = dict(reviewed_glossary)
|
| 2957 |
+
# A corrected canonical identity invalidates only target-language
|
| 2958 |
+
# work. Sealed English remains authoritative and unchanged.
|
| 2959 |
+
translation_sections = {}
|
| 2960 |
+
state['translation_sections'] = {}
|
| 2961 |
+
state['full_draft_translated'] = ''
|
| 2962 |
+
state['translation_complete'] = False
|
| 2963 |
+
state['package_validated'] = False
|
| 2964 |
+
state['translation_glossary'] = translation_glossary
|
| 2965 |
+
state['canonical_glossary_quality_version'] = glossary_quality_version
|
| 2966 |
+
self._checkpoint('canonical_glossary_quality_ready', state, 15)
|
| 2967 |
+
|
| 2968 |
current_entity_ledger_version = str(
|
| 2969 |
getattr(translation_agent, 'PROTECTED_ENTITY_LEDGER_VERSION', 'protected-entity-ledger-v1')
|
| 2970 |
or 'protected-entity-ledger-v1'
|
|
|
|
| 3129 |
quote_invocation_plan, adaptive_structure, data_metadata
|
| 3130 |
)
|
| 3131 |
|
| 3132 |
+
hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {}) if mode == "amj" else {}
|
| 3133 |
total_steps = len(adaptive_structure)
|
| 3134 |
for sec in adaptive_structure:
|
| 3135 |
display_name = sec["display_name"]
|
|
|
|
| 3213 |
" Use level-2 or level-3 headings only when they clarify the argument; "
|
| 3214 |
"never use level-4 headings or numbered headings."
|
| 3215 |
)
|
| 3216 |
+
if mode == "amj" and section_id == "results" and data_metadata.get("quantitative_table_manifest"):
|
| 3217 |
+
table_rule = (
|
| 3218 |
+
"AUTHORITATIVE TABLE CONTRACT: Do not create, reproduce, renumber, or alter any Markdown statistical table. "
|
| 3219 |
+
"The Executor will attach the required reader-facing tables deterministically from the quantitative evidence manifest. "
|
| 3220 |
+
"You may discuss and refer to Table 1-Table 4 where relevant, but do not invent an additional table or change a table number."
|
| 3221 |
+
)
|
| 3222 |
+
elif sec["is_method_or_result"] and mode == "amj":
|
| 3223 |
+
table_rule = "Present only study-supported statistical evidence; do not invent tables or numerical results."
|
| 3224 |
+
else:
|
| 3225 |
+
table_rule = ""
|
| 3226 |
concepts = ", ".join(sec.get("concepts") or [])
|
| 3227 |
theme_evidence = [item for item in (sec.get("theme_evidence") or []) if isinstance(item, dict)]
|
| 3228 |
evidence_profiles = []
|
|
|
|
| 3302 |
{heading_directive}
|
| 3303 |
{citation_rule}
|
| 3304 |
{table_rule}
|
| 3305 |
+
AUTHOR-FACING PURITY: Write only reader-facing scholarly prose. Never mention SmartPaper control labels such as frozen evidence, evidence packet, evidence ledger, study-owned/result-owned fact, frozen theme summary, frozen thematic structure, or frozen task-level concepts. Describe the study, method, evidence, and limitations directly.
|
| 3306 |
+
{self._hypothesis_contract_directive(hypothesis_ledger)}
|
| 3307 |
{str(sec.get('required_content') or '')}
|
| 3308 |
{qualitative_rule}
|
| 3309 |
{anti_repeat}
|
|
|
|
| 3448 |
section_id=section_id, section_name=display_name,
|
| 3449 |
evidence_contract=evidence_packet,
|
| 3450 |
include_critic=not deterministic_appendix,
|
| 3451 |
+
citation_violations=citation_semantic_rows, hypothesis_ledger=hypothesis_ledger,
|
| 3452 |
)
|
| 3453 |
non_citation_count = max(
|
| 3454 |
0, len(phase1_validation_rows) - len(citation_semantic_rows)
|
|
|
|
| 3550 |
quote_invocation_plan=quote_invocation_plan,
|
| 3551 |
)
|
| 3552 |
|
| 3553 |
+
if mode == "amj" and data_metadata.get("quantitative_table_manifest"):
|
| 3554 |
+
if section_id == "results":
|
| 3555 |
+
en_resolved = self._apply_quantitative_table_contract(
|
| 3556 |
+
en_resolved, data_metadata, display_name
|
| 3557 |
+
)
|
| 3558 |
+
else:
|
| 3559 |
+
# System-generated AMJ tables have one authoritative
|
| 3560 |
+
# manifest. Writer-created tables anywhere else are not
|
| 3561 |
+
# permitted to create a fifth/competing artifact table.
|
| 3562 |
+
en_resolved = self._strip_model_markdown_tables(en_resolved)
|
| 3563 |
+
|
| 3564 |
# S0-R1 phase 2: only local/idempotent structural normalizers may
|
| 3565 |
# mutate the candidate before the final pure validation bundle.
|
| 3566 |
en_resolved, enumeration_rows = agent.repair_and_validate_enumeration_contract(
|
|
|
|
| 3610 |
mode, agent, quant_engine, en_resolved, data_metadata,
|
| 3611 |
section_id=section_id, section_name=display_name,
|
| 3612 |
evidence_contract=evidence_packet, include_critic=False,
|
| 3613 |
+
hypothesis_ledger=hypothesis_ledger,
|
| 3614 |
)
|
| 3615 |
if post_validation_rows:
|
| 3616 |
if section_attempt < max_section_attempts:
|
|
|
|
| 3688 |
frozen_section = self._extract_top_level_section(full_draft_en, sec_title)
|
| 3689 |
if not frozen_section:
|
| 3690 |
raise RuntimeError(f"english_section_seal_source_missing:{sec_title}")
|
| 3691 |
+
|
| 3692 |
+
# C5: one bounded publication-quality evidence opportunity is
|
| 3693 |
+
# allowed before the English top-level Section is sealed and
|
| 3694 |
+
# before translation begins. It never writes new prose and may
|
| 3695 |
+
# attach only citations that survive the same semantic auditor.
|
| 3696 |
+
if section_key in citation_top_level_minimums:
|
| 3697 |
+
top_units = [
|
| 3698 |
+
unit for unit, owner in citation_unit_section_map.items()
|
| 3699 |
+
if str(owner) == str(section_key)
|
| 3700 |
+
]
|
| 3701 |
+
top_validated = set()
|
| 3702 |
+
top_claims = 0
|
| 3703 |
+
for unit in top_units:
|
| 3704 |
+
top_validated.update(citation_validated_tags_by_unit.get(unit) or [])
|
| 3705 |
+
top_claims += int(citation_required_claim_counts_by_unit.get(unit, 0) or 0)
|
| 3706 |
+
top_required = min(
|
| 3707 |
+
int(citation_top_level_minimums.get(section_key, 0) or 0),
|
| 3708 |
+
max(0, top_claims),
|
| 3709 |
+
) if top_claims else 0
|
| 3710 |
+
if len(top_validated) < top_required:
|
| 3711 |
+
completed_section, added_tags = self._bounded_top_level_evidence_completion(
|
| 3712 |
+
agent, topic=topic, section_id=section_key, section_name=sec_title,
|
| 3713 |
+
section_text=frozen_section, required=top_required,
|
| 3714 |
+
existing_tags=top_validated, excluded_tags=set(agent.used_citations),
|
| 3715 |
+
)
|
| 3716 |
+
if added_tags and completed_section != frozen_section:
|
| 3717 |
+
full_draft_en = self._replace_top_level_section(
|
| 3718 |
+
full_draft_en, sec_title, completed_section
|
| 3719 |
+
)
|
| 3720 |
+
frozen_section = completed_section
|
| 3721 |
+
merged_tags = set(citation_validated_tags_by_unit.get(display_name) or [])
|
| 3722 |
+
merged_tags.update(added_tags)
|
| 3723 |
+
citation_validated_tags_by_unit[display_name] = sorted(
|
| 3724 |
+
merged_tags, key=agent._citation_tag_number
|
| 3725 |
+
)
|
| 3726 |
+
logging.info(
|
| 3727 |
+
"Top-level evidence completion sealed | section=%s added=%s",
|
| 3728 |
+
sec_title, len(added_tags),
|
| 3729 |
+
)
|
| 3730 |
+
|
| 3731 |
section_integrity_seals[section_key] = self._build_section_integrity_seal(
|
| 3732 |
section_key, sec_title, frozen_section
|
| 3733 |
)
|
|
|
|
| 3807 |
agent.validate_enumeration_contract(
|
| 3808 |
sealed_section, section_name=f"Final sealed section: {section_title}"
|
| 3809 |
)
|
| 3810 |
+
internal_rows = self._author_facing_internal_workflow_violations(sealed_section)
|
| 3811 |
+
if internal_rows:
|
| 3812 |
+
raise RuntimeError(
|
| 3813 |
+
f"final_author_facing_purity_failed:{section_title}:{internal_rows[:3]}"
|
| 3814 |
+
)
|
| 3815 |
+
if mode == "amj" and hypothesis_ledger:
|
| 3816 |
+
hypothesis_rows = self._hypothesis_contract_violations(
|
| 3817 |
+
full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
|
| 3818 |
+
)
|
| 3819 |
+
if hypothesis_rows:
|
| 3820 |
+
raise RuntimeError(f"final_hypothesis_contract_failed:{hypothesis_rows[:3]}")
|
| 3821 |
# Validate the completed body while the Working Abstract is still in
|
| 3822 |
# place. Final front matter must not be generated until all body
|
| 3823 |
# sections, citation cleanup, and any Discussion rebalance are frozen.
|
|
|
|
| 4156 |
if str(state.get("evidence_status") or "") == "limited":
|
| 4157 |
result.update({
|
| 4158 |
"quality_status": "evidence_limited",
|
| 4159 |
+
"submission_readiness": "review_required",
|
| 4160 |
"safe_error_code": "W_EVIDENCE_COVERAGE_LIMITED",
|
| 4161 |
"safe_error_detail": self._evidence_warning_detail(),
|
| 4162 |
})
|
|
|
|
| 4202 |
if str(state.get("evidence_status") or "") == "limited":
|
| 4203 |
result.update({
|
| 4204 |
"quality_status": "evidence_limited",
|
| 4205 |
+
"submission_readiness": "review_required",
|
| 4206 |
"safe_error_code": "W_EVIDENCE_COVERAGE_LIMITED",
|
| 4207 |
"safe_error_detail": self._evidence_warning_detail(),
|
| 4208 |
})
|
core/pipeline_agent.py
CHANGED
|
@@ -73,6 +73,7 @@ class SSciPipelineAgent:
|
|
| 73 |
TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
|
| 74 |
MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS = 2
|
| 75 |
PROTECTED_ENTITY_LEDGER_VERSION = 'protected-entity-ledger-v2'
|
|
|
|
| 76 |
|
| 77 |
def __init__(self, ds_key, s2_key=""):
|
| 78 |
self.ds_key = str(ds_key or "").strip()
|
|
@@ -6491,6 +6492,106 @@ TEXT:
|
|
| 6491 |
mapping[term] = target
|
| 6492 |
return mapping
|
| 6493 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6494 |
def extend_translation_glossary(self, body, target_lang, existing=None):
|
| 6495 |
"""Extend a task-level glossary without remapping an existing term."""
|
| 6496 |
mapping = dict(existing or {})
|
|
|
|
| 73 |
TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
|
| 74 |
MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS = 2
|
| 75 |
PROTECTED_ENTITY_LEDGER_VERSION = 'protected-entity-ledger-v2'
|
| 76 |
+
CANONICAL_GLOSSARY_QUALITY_VERSION = 'canonical-glossary-quality-v1'
|
| 77 |
|
| 78 |
def __init__(self, ds_key, s2_key=""):
|
| 79 |
self.ds_key = str(ds_key or "").strip()
|
|
|
|
| 6492 |
mapping[term] = target
|
| 6493 |
return mapping
|
| 6494 |
|
| 6495 |
+
@staticmethod
|
| 6496 |
+
def _canonical_target_surface_suspicious(target, target_lang):
|
| 6497 |
+
"""Detect only high-confidence malformed canonical-term surfaces."""
|
| 6498 |
+
value = re.sub(r'\s+', ' ', str(target or '')).strip()
|
| 6499 |
+
if not value:
|
| 6500 |
+
return True
|
| 6501 |
+
profile = SSciPipelineAgent._translation_language_profile(target_lang)
|
| 6502 |
+
if profile.get('language_code') == 'zh':
|
| 6503 |
+
compact = re.sub(r'\s+', '', value)
|
| 6504 |
+
# Repeated multi-character fragments such as 情境感知感知 are a
|
| 6505 |
+
# high-confidence malformed canonical surface, while single-character
|
| 6506 |
+
# reduplication is left alone because it can be grammatical Chinese.
|
| 6507 |
+
if re.search(r'([\u4e00-\u9fff]{2,4})\1', compact):
|
| 6508 |
+
return True
|
| 6509 |
+
return False
|
| 6510 |
+
|
| 6511 |
+
def validate_and_refine_canonical_concept_glossary(self, concept_ledger, target_lang, glossary):
|
| 6512 |
+
"""Independently review strict canonical terms once before body translation.
|
| 6513 |
+
|
| 6514 |
+
The first glossary call establishes candidate identities. This second,
|
| 6515 |
+
bounded call verifies semantic identity and natural academic surface form
|
| 6516 |
+
before those candidates become immutable for all later ZH/RU/KK sections.
|
| 6517 |
+
At most one corrected target is accepted for each strict construct; no
|
| 6518 |
+
section-level repair loop is involved.
|
| 6519 |
+
"""
|
| 6520 |
+
mapping = dict(glossary or {})
|
| 6521 |
+
rows = (dict(concept_ledger or {}).get('items') or []) if isinstance(concept_ledger, dict) else []
|
| 6522 |
+
strict_sources = []
|
| 6523 |
+
for row in rows:
|
| 6524 |
+
if not isinstance(row, dict):
|
| 6525 |
+
continue
|
| 6526 |
+
if str(row.get('policy') or '').strip().upper() != 'STRICT_CONSTRUCT':
|
| 6527 |
+
continue
|
| 6528 |
+
source = re.sub(r'\s+', ' ', str(row.get('source') or '')).strip()
|
| 6529 |
+
if source and source in mapping and source not in strict_sources:
|
| 6530 |
+
strict_sources.append(source)
|
| 6531 |
+
if not strict_sources:
|
| 6532 |
+
return mapping
|
| 6533 |
+
|
| 6534 |
+
payload = [
|
| 6535 |
+
{'source': source, 'candidate_target': str(mapping.get(source) or '').strip()}
|
| 6536 |
+
for source in strict_sources
|
| 6537 |
+
]
|
| 6538 |
+
if any(not row['candidate_target'] for row in payload):
|
| 6539 |
+
raise DeepSeekRequestError('Canonical glossary quality review received an empty strict target.')
|
| 6540 |
+
language_rule = self._translation_language_rule(target_lang)
|
| 6541 |
+
response = self.call_deepseek(
|
| 6542 |
+
(
|
| 6543 |
+
f"Independently review strict research-construct translations for {target_lang}. {language_rule} "
|
| 6544 |
+
"Judge semantic identity and natural academic terminology, not stylistic preference. "
|
| 6545 |
+
"Reject duplicated/malformed wording, concept narrowing/broadening, or a target that changes the construct. "
|
| 6546 |
+
"If the candidate is sound, return it unchanged with accepted=true. If it is materially flawed, return exactly one corrected canonical target with accepted=false. "
|
| 6547 |
+
"Do not merge distinct constructs. Return JSON only as "
|
| 6548 |
+
'{"terms":[{"source":"...","candidate_target":"...","accepted":true,"target":"..."}]}'
|
| 6549 |
+
" and return every source exactly once."
|
| 6550 |
+
),
|
| 6551 |
+
json.dumps({'terms': payload}, ensure_ascii=False),
|
| 6552 |
+
max_tokens=max(1200, min(4200, 180 + len(payload) * 120)),
|
| 6553 |
+
temp=0.0, model_role='translation', thinking=False, allow_fallback=False,
|
| 6554 |
+
)
|
| 6555 |
+
parsed = self._extract_json_from_text(response)
|
| 6556 |
+
items = parsed.get('terms', []) if isinstance(parsed, dict) else []
|
| 6557 |
+
returned = {}
|
| 6558 |
+
for item in items:
|
| 6559 |
+
if not isinstance(item, dict):
|
| 6560 |
+
continue
|
| 6561 |
+
source = re.sub(r'\s+', ' ', str(item.get('source') or '')).strip()
|
| 6562 |
+
if source:
|
| 6563 |
+
returned[source] = item
|
| 6564 |
+
if set(returned) != set(strict_sources):
|
| 6565 |
+
raise DeepSeekRequestError('Canonical glossary quality review omitted or changed a strict source identity.')
|
| 6566 |
+
|
| 6567 |
+
refined = dict(mapping)
|
| 6568 |
+
for source in strict_sources:
|
| 6569 |
+
item = returned[source]
|
| 6570 |
+
candidate = str(mapping.get(source) or '').strip()
|
| 6571 |
+
accepted_raw = item.get('accepted')
|
| 6572 |
+
accepted = accepted_raw is True or str(accepted_raw).strip().casefold() == 'true'
|
| 6573 |
+
reviewed_target = re.sub(r'\s+', ' ', str(item.get('target') or '')).strip()
|
| 6574 |
+
suspicious = self._canonical_target_surface_suspicious(candidate, target_lang)
|
| 6575 |
+
if accepted and not suspicious:
|
| 6576 |
+
# An acceptance cannot silently remap the frozen candidate.
|
| 6577 |
+
if reviewed_target and reviewed_target != candidate:
|
| 6578 |
+
raise DeepSeekRequestError('Canonical glossary review changed an accepted strict target.')
|
| 6579 |
+
continue
|
| 6580 |
+
if not reviewed_target or reviewed_target == candidate:
|
| 6581 |
+
raise DeepSeekRequestError(
|
| 6582 |
+
f'Canonical concept quality review did not provide a usable correction: {source[:80]}'
|
| 6583 |
+
)
|
| 6584 |
+
if reviewed_target.casefold() == source.casefold() and re.search(r'[a-z]{4,}', source):
|
| 6585 |
+
raise DeepSeekRequestError(f'Canonical concept remained untranslated after quality review: {source[:80]}')
|
| 6586 |
+
if self._canonical_target_surface_suspicious(reviewed_target, target_lang):
|
| 6587 |
+
raise DeepSeekRequestError(f'Canonical concept correction remains malformed: {source[:80]}')
|
| 6588 |
+
refined[source] = reviewed_target
|
| 6589 |
+
self._record_translation_assurance(
|
| 6590 |
+
'CANONICAL_GLOSSARY_REFINED', target_lang=target_lang, severity='repair',
|
| 6591 |
+
decision='REPAIR_LOCAL', detail={'source': source[:120]},
|
| 6592 |
+
)
|
| 6593 |
+
return refined
|
| 6594 |
+
|
| 6595 |
def extend_translation_glossary(self, body, target_lang, existing=None):
|
| 6596 |
"""Extend a task-level glossary without remapping an existing term."""
|
| 6597 |
mapping = dict(existing or {})
|
core/production_guard.py
CHANGED
|
@@ -56,6 +56,80 @@ def validate_docx_bytes(data: bytes, *, minimum_paragraphs: int = 8) -> None:
|
|
| 56 |
)
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def validate_qualitative_appendix_docx_layout(data: bytes, *, label: str = "DOCX") -> None:
|
| 60 |
"""Fail closed if the qualitative evidence Appendix lost its layout contract.
|
| 61 |
|
|
@@ -512,6 +586,7 @@ def build_evidence_coverage_report(summary: dict[str, Any] | None) -> str:
|
|
| 512 |
"SmartPaper Evidence Coverage Report",
|
| 513 |
"",
|
| 514 |
f"Overall evidence status: {'LIMITED' if limited else 'ADEQUATE'}",
|
|
|
|
| 515 |
f"Validated references in final manuscript: {max(0, int(data.get('final_reference_count') or 0))}",
|
| 516 |
f"Early coverage risk detected: {'yes' if bool(data.get('early_risk_detected')) else 'no'}",
|
| 517 |
"",
|
|
@@ -546,6 +621,7 @@ def build_evidence_coverage_report(summary: dict[str, Any] | None) -> str:
|
|
| 546 |
"Author guidance:",
|
| 547 |
"The manuscript was completed because structural, translation, citation-correctness, quotation/fact, DOCX, and ZIP integrity gates are independent from literature coverage quantity.",
|
| 548 |
"Before formal submission, review the evidence gaps and consider adding more directly relevant core literature where appropriate.",
|
|
|
|
| 549 |
])
|
| 550 |
return "\n".join(lines).strip() + "\n"
|
| 551 |
|
|
@@ -585,6 +661,14 @@ def build_validated_package(
|
|
| 585 |
raise OutputValidationError(str(exc)) from exc
|
| 586 |
validate_docx_bytes(english_docx)
|
| 587 |
validate_docx_bytes(translated_docx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
if qualitative_mode:
|
| 589 |
validate_qualitative_appendix_docx_layout(english_docx, label="English DOCX")
|
| 590 |
validate_qualitative_appendix_docx_layout(translated_docx, label="Target-language DOCX")
|
|
@@ -621,6 +705,12 @@ def build_validated_package(
|
|
| 621 |
for name in (english_filename, translated_filename):
|
| 622 |
archived_docx = archive.read(name)
|
| 623 |
validate_docx_bytes(archived_docx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
if qualitative_mode:
|
| 625 |
validate_qualitative_appendix_docx_layout(
|
| 626 |
archived_docx,
|
|
|
|
| 56 |
)
|
| 57 |
|
| 58 |
|
| 59 |
+
def _table_reference_numbers(markdown: str) -> set[int]:
|
| 60 |
+
value = str(markdown or "")
|
| 61 |
+
# The References tail can contain article titles with the word "Table"; it
|
| 62 |
+
# is bibliographic data and must not be mistaken for a manuscript table
|
| 63 |
+
# cross-reference.
|
| 64 |
+
reference_heading = re.search(
|
| 65 |
+
r"(?mi)^#\s+(?:References|Reference List|参考文献|Литература|Список литературы|Әдебиеттер|Пайдаланылған әдебиеттер)\s*$",
|
| 66 |
+
value,
|
| 67 |
+
)
|
| 68 |
+
if reference_heading:
|
| 69 |
+
value = value[:reference_heading.start()]
|
| 70 |
+
numbers = set()
|
| 71 |
+
patterns = [
|
| 72 |
+
r"\bTable\s+(\d+)\b",
|
| 73 |
+
r"表\s*(\d+)",
|
| 74 |
+
r"\bТаблиц(?:а|ы|е|у|ей|ами|ах)?\s*(\d+)\b",
|
| 75 |
+
r"\bКесте\s*(\d+)\b",
|
| 76 |
+
r"\b(\d+)\s*[-–—]?\s*кесте\b",
|
| 77 |
+
]
|
| 78 |
+
for pattern in patterns:
|
| 79 |
+
for match in re.finditer(pattern, value, flags=re.I):
|
| 80 |
+
try:
|
| 81 |
+
numbers.add(int(match.group(1)))
|
| 82 |
+
except Exception:
|
| 83 |
+
continue
|
| 84 |
+
return numbers
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def validate_quantitative_table_contract(
|
| 88 |
+
markdown: str, data: bytes, manifest: dict[str, Any] | None, *, label: str = "DOCX"
|
| 89 |
+
) -> None:
|
| 90 |
+
"""Validate table inventory/reference parity for the system-generated AMJ path."""
|
| 91 |
+
record = dict(manifest or {})
|
| 92 |
+
rows = [item for item in (record.get("tables") or []) if isinstance(item, dict)]
|
| 93 |
+
if not rows:
|
| 94 |
+
return
|
| 95 |
+
expected_numbers = {
|
| 96 |
+
int(item.get("number") or 0) for item in rows if int(item.get("number") or 0) > 0
|
| 97 |
+
}
|
| 98 |
+
if not expected_numbers:
|
| 99 |
+
raise OutputValidationError(f"{label} quantitative table manifest is empty or invalid.")
|
| 100 |
+
try:
|
| 101 |
+
document = Document(io.BytesIO(data))
|
| 102 |
+
except Exception as exc:
|
| 103 |
+
raise OutputValidationError(f"{label} quantitative table contract cannot inspect DOCX: {exc}") from exc
|
| 104 |
+
if len(document.tables) != len(expected_numbers):
|
| 105 |
+
raise OutputValidationError(
|
| 106 |
+
f"{label} quantitative table inventory mismatch: actual={len(document.tables)}, expected={len(expected_numbers)}."
|
| 107 |
+
)
|
| 108 |
+
referenced = _table_reference_numbers(markdown)
|
| 109 |
+
unexpected = sorted(referenced - expected_numbers)
|
| 110 |
+
missing = sorted(expected_numbers - referenced)
|
| 111 |
+
if unexpected:
|
| 112 |
+
raise OutputValidationError(
|
| 113 |
+
f"{label} references unexpected quantitative table number(s): {unexpected}."
|
| 114 |
+
)
|
| 115 |
+
if missing:
|
| 116 |
+
raise OutputValidationError(
|
| 117 |
+
f"{label} is missing reader-facing table number(s): {missing}."
|
| 118 |
+
)
|
| 119 |
+
if str(label).casefold().startswith("english"):
|
| 120 |
+
for item in rows:
|
| 121 |
+
number = int(item.get("number") or 0)
|
| 122 |
+
title = str(item.get("title") or "").strip()
|
| 123 |
+
if not number or not title:
|
| 124 |
+
continue
|
| 125 |
+
if not re.search(
|
| 126 |
+
rf"(?mi)^\s*Table\s+{number}\s*:\s*{re.escape(title)}\s*$", str(markdown or "")
|
| 127 |
+
):
|
| 128 |
+
raise OutputValidationError(
|
| 129 |
+
f"English manuscript table caption mismatch for Table {number}: {title}."
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
def validate_qualitative_appendix_docx_layout(data: bytes, *, label: str = "DOCX") -> None:
|
| 134 |
"""Fail closed if the qualitative evidence Appendix lost its layout contract.
|
| 135 |
|
|
|
|
| 586 |
"SmartPaper Evidence Coverage Report",
|
| 587 |
"",
|
| 588 |
f"Overall evidence status: {'LIMITED' if limited else 'ADEQUATE'}",
|
| 589 |
+
f"Submission readiness: {'REVIEW REQUIRED — evidence coverage is limited' if limited else 'QUALITY GATE PASSED'}",
|
| 590 |
f"Validated references in final manuscript: {max(0, int(data.get('final_reference_count') or 0))}",
|
| 591 |
f"Early coverage risk detected: {'yes' if bool(data.get('early_risk_detected')) else 'no'}",
|
| 592 |
"",
|
|
|
|
| 621 |
"Author guidance:",
|
| 622 |
"The manuscript was completed because structural, translation, citation-correctness, quotation/fact, DOCX, and ZIP integrity gates are independent from literature coverage quantity.",
|
| 623 |
"Before formal submission, review the evidence gaps and consider adding more directly relevant core literature where appropriate.",
|
| 624 |
+
"Do not treat an evidence-limited package as submission-ready until the evidence gaps have been reviewed and, where possible, strengthened.",
|
| 625 |
])
|
| 626 |
return "\n".join(lines).strip() + "\n"
|
| 627 |
|
|
|
|
| 661 |
raise OutputValidationError(str(exc)) from exc
|
| 662 |
validate_docx_bytes(english_docx)
|
| 663 |
validate_docx_bytes(translated_docx)
|
| 664 |
+
quantitative_manifest = (data_metadata or {}).get("quantitative_table_manifest") if str(mode or "").strip().casefold() == "amj" else None
|
| 665 |
+
if quantitative_manifest:
|
| 666 |
+
validate_quantitative_table_contract(
|
| 667 |
+
english_markdown, english_docx, quantitative_manifest, label="English DOCX"
|
| 668 |
+
)
|
| 669 |
+
validate_quantitative_table_contract(
|
| 670 |
+
translated_markdown, translated_docx, quantitative_manifest, label="Target-language DOCX"
|
| 671 |
+
)
|
| 672 |
if qualitative_mode:
|
| 673 |
validate_qualitative_appendix_docx_layout(english_docx, label="English DOCX")
|
| 674 |
validate_qualitative_appendix_docx_layout(translated_docx, label="Target-language DOCX")
|
|
|
|
| 705 |
for name in (english_filename, translated_filename):
|
| 706 |
archived_docx = archive.read(name)
|
| 707 |
validate_docx_bytes(archived_docx)
|
| 708 |
+
if quantitative_manifest:
|
| 709 |
+
archived_markdown = english_markdown if name == english_filename else translated_markdown
|
| 710 |
+
validate_quantitative_table_contract(
|
| 711 |
+
archived_markdown, archived_docx, quantitative_manifest,
|
| 712 |
+
label="Archived English DOCX" if name == english_filename else "Archived target-language DOCX",
|
| 713 |
+
)
|
| 714 |
if qualitative_mode:
|
| 715 |
validate_qualitative_appendix_docx_layout(
|
| 716 |
archived_docx,
|
core/quantitative_engine.py
CHANGED
|
@@ -18,6 +18,113 @@ class QuantitativeEngine:
|
|
| 18 |
"""Prepare one frozen quantitative evidence packet before manuscript writing."""
|
| 19 |
|
| 20 |
QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-v2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
@staticmethod
|
| 23 |
def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
|
|
@@ -989,6 +1096,10 @@ Keywords: {keywords}"""
|
|
| 989 |
"Frozen bootstrap and collinearity checks",
|
| 990 |
],
|
| 991 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 992 |
metadata["quantitative_evidence_ledger"] = self.build_quantitative_evidence_ledger(metadata, generated_prompt)
|
| 993 |
return (
|
| 994 |
df,
|
|
|
|
| 18 |
"""Prepare one frozen quantitative evidence packet before manuscript writing."""
|
| 19 |
|
| 20 |
QUANTITATIVE_EVIDENCE_LEDGER_VERSION = "quantitative-evidence-ledger-v2"
|
| 21 |
+
QUANTITATIVE_TABLE_MANIFEST_VERSION = "quantitative-table-manifest-v1"
|
| 22 |
+
HYPOTHESIS_LEDGER_VERSION = "quantitative-hypothesis-ledger-v1"
|
| 23 |
+
|
| 24 |
+
@classmethod
|
| 25 |
+
def build_hypothesis_ledger(cls, constructs):
|
| 26 |
+
"""Freeze one result-independent AMJ hypothesis identity contract.
|
| 27 |
+
|
| 28 |
+
The system-generated quantitative design is observational and does not
|
| 29 |
+
justify result-driven directional hypotheses. The three numbered
|
| 30 |
+
hypotheses therefore freeze only the construct pairing/association
|
| 31 |
+
identity. The indirect association is an analysis target, not H4.
|
| 32 |
+
"""
|
| 33 |
+
values = [re.sub(r"\s+", " ", str(item or "")).strip() for item in (constructs or [])]
|
| 34 |
+
if len(values) != 3 or any(not item for item in values):
|
| 35 |
+
return {}
|
| 36 |
+
predictor, mediator, outcome = values
|
| 37 |
+
items = [
|
| 38 |
+
{"hypothesis_id": "H1", "predictor": predictor, "outcome": outcome, "relation": "association"},
|
| 39 |
+
{"hypothesis_id": "H2", "predictor": predictor, "outcome": mediator, "relation": "association"},
|
| 40 |
+
{"hypothesis_id": "H3", "predictor": mediator, "outcome": outcome, "relation": "association"},
|
| 41 |
+
]
|
| 42 |
+
return {
|
| 43 |
+
"version": cls.HYPOTHESIS_LEDGER_VERSION,
|
| 44 |
+
"items": items,
|
| 45 |
+
"indirect_analysis": {
|
| 46 |
+
"predictor": predictor,
|
| 47 |
+
"mediator": mediator,
|
| 48 |
+
"outcome": outcome,
|
| 49 |
+
"numbered_hypothesis": False,
|
| 50 |
+
"relation": "estimated_indirect_association",
|
| 51 |
+
},
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
@classmethod
|
| 55 |
+
def build_table_manifest_from_evidence_markdown(cls, evidence_markdown):
|
| 56 |
+
"""Rebuild the synthetic table manifest from a durable pre-C5 evidence packet."""
|
| 57 |
+
value = str(evidence_markdown or "")
|
| 58 |
+
targets = {
|
| 59 |
+
1: "Descriptive Statistics and Measurement Assessment",
|
| 60 |
+
2: "Descriptive Statistics and Measurement Assessment",
|
| 61 |
+
3: "Hypothesis and Association Tests",
|
| 62 |
+
4: "Additional Analyses and Robustness Checks",
|
| 63 |
+
}
|
| 64 |
+
rows = []
|
| 65 |
+
pattern = re.compile(
|
| 66 |
+
r"(?ms)^###\s+Table\s+(?P<number>[1-4])\s*:\s*(?P<title>[^\n]+)\n(?P<body>.*?)(?=^###\s+|\Z)"
|
| 67 |
+
)
|
| 68 |
+
for match in pattern.finditer(value):
|
| 69 |
+
number = int(match.group("number"))
|
| 70 |
+
body = match.group("body").strip()
|
| 71 |
+
table_lines = []
|
| 72 |
+
note = ""
|
| 73 |
+
for line in body.splitlines():
|
| 74 |
+
stripped = line.strip()
|
| 75 |
+
if stripped.startswith("|"):
|
| 76 |
+
table_lines.append(line.rstrip())
|
| 77 |
+
elif re.match(r"^\*?Note\s*:", stripped, flags=re.I):
|
| 78 |
+
note = re.sub(r"^\*?Note\s*:\s*", "", stripped, flags=re.I).strip("* ")
|
| 79 |
+
elif table_lines:
|
| 80 |
+
break
|
| 81 |
+
if len(table_lines) < 2:
|
| 82 |
+
continue
|
| 83 |
+
title = re.sub(r"^Frozen\s+", "", match.group("title").strip(), flags=re.I)
|
| 84 |
+
row = {
|
| 85 |
+
"number": number,
|
| 86 |
+
"title": title,
|
| 87 |
+
"target_subsection": targets[number],
|
| 88 |
+
"markdown": "\n".join(table_lines),
|
| 89 |
+
}
|
| 90 |
+
if note:
|
| 91 |
+
row["note"] = note
|
| 92 |
+
rows.append(row)
|
| 93 |
+
if {row["number"] for row in rows} != {1, 2, 3, 4}:
|
| 94 |
+
return {}
|
| 95 |
+
return {"version": cls.QUANTITATIVE_TABLE_MANIFEST_VERSION, "tables": sorted(rows, key=lambda row: row["number"])}
|
| 96 |
+
|
| 97 |
+
@classmethod
|
| 98 |
+
def build_table_manifest(cls, validity_df, fl_matrix_df, path_table, spearman):
|
| 99 |
+
"""Return the exact reader-facing tables required by the synthetic AMJ path."""
|
| 100 |
+
rows = [
|
| 101 |
+
{
|
| 102 |
+
"number": 1,
|
| 103 |
+
"title": "Reliability and Convergent Validity",
|
| 104 |
+
"target_subsection": "Descriptive Statistics and Measurement Assessment",
|
| 105 |
+
"markdown": validity_df.to_markdown(index=False),
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"number": 2,
|
| 109 |
+
"title": "Descriptive Statistics and Discriminant Validity (Fornell-Larcker Criterion)",
|
| 110 |
+
"target_subsection": "Descriptive Statistics and Measurement Assessment",
|
| 111 |
+
"note": "Diagonal elements in parentheses are the square roots of AVE.",
|
| 112 |
+
"markdown": fl_matrix_df.to_markdown(),
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"number": 3,
|
| 116 |
+
"title": "Association Model Results",
|
| 117 |
+
"target_subsection": "Hypothesis and Association Tests",
|
| 118 |
+
"markdown": path_table.to_markdown(index=False),
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
"number": 4,
|
| 122 |
+
"title": "Rank-Order Robustness Correlations (Spearman)",
|
| 123 |
+
"target_subsection": "Additional Analyses and Robustness Checks",
|
| 124 |
+
"markdown": spearman.round(3).to_markdown(),
|
| 125 |
+
},
|
| 126 |
+
]
|
| 127 |
+
return {"version": cls.QUANTITATIVE_TABLE_MANIFEST_VERSION, "tables": rows}
|
| 128 |
|
| 129 |
@staticmethod
|
| 130 |
def generate_psd_corr_matrix(n_constructs, max_corr=0.5):
|
|
|
|
| 1096 |
"Frozen bootstrap and collinearity checks",
|
| 1097 |
],
|
| 1098 |
}
|
| 1099 |
+
metadata["hypothesis_ledger"] = self.build_hypothesis_ledger(construct_names)
|
| 1100 |
+
metadata["quantitative_table_manifest"] = self.build_table_manifest(
|
| 1101 |
+
validity_df, fl_matrix_df, path_table, spearman
|
| 1102 |
+
)
|
| 1103 |
metadata["quantitative_evidence_ledger"] = self.build_quantitative_evidence_ledger(metadata, generated_prompt)
|
| 1104 |
return (
|
| 1105 |
df,
|