Spaces:
Running
Running
S0-R1-C5.2 Frozen Citation Whitelist Four-Language Retrieval Anchor
#80
by GuXiaopeng - opened
- core/generation_runner.py +195 -41
- core/pipeline_agent.py +252 -45
core/generation_runner.py
CHANGED
|
@@ -1047,6 +1047,36 @@ class GenerationRunner:
|
|
| 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
|
|
@@ -1081,45 +1111,43 @@ class GenerationRunner:
|
|
| 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 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1090 |
}
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 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:
|
|
@@ -2448,6 +2476,15 @@ class GenerationRunner:
|
|
| 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 |
)
|
|
@@ -2522,12 +2559,24 @@ class GenerationRunner:
|
|
| 2522 |
agent.stream_generate_research_logic(
|
| 2523 |
topic, keywords, synthesis_result, refs_text,
|
| 2524 |
data_evidence=planning_data_md, mode=mode,
|
|
|
|
| 2525 |
)
|
| 2526 |
)
|
| 2527 |
agent.record_prewrite_stage("research_logic", prewrite_started, prewrite_calls)
|
| 2528 |
research_logic_contract = re.sub(
|
| 2529 |
r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
|
| 2530 |
).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2531 |
if not research_logic_contract:
|
| 2532 |
raise RuntimeError("research_logic_contract_missing")
|
| 2533 |
agent.global_memory["research_logic_contract"] = research_logic_contract
|
|
@@ -2536,8 +2585,13 @@ class GenerationRunner:
|
|
| 2536 |
working_front = agent.generate_working_front_matter(
|
| 2537 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 2538 |
data_evidence=planning_data_md, mode=mode,
|
|
|
|
| 2539 |
)
|
| 2540 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2541 |
working_keywords = list(working_front.get("keywords") or [])
|
| 2542 |
if not working_abstract or not working_keywords:
|
| 2543 |
raise RuntimeError("working_front_matter_missing")
|
|
@@ -2545,6 +2599,10 @@ class GenerationRunner:
|
|
| 2545 |
mode, agent, quant_engine, working_abstract, data_metadata,
|
| 2546 |
section_name="Working Abstract",
|
| 2547 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2548 |
if front_fact_rows:
|
| 2549 |
feedback = self._section_retry_feedback(front_fact_rows)
|
| 2550 |
logging.warning(
|
|
@@ -2554,12 +2612,21 @@ class GenerationRunner:
|
|
| 2554 |
working_front = agent.generate_working_front_matter(
|
| 2555 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 2556 |
data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
|
|
|
|
| 2557 |
)
|
| 2558 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2559 |
working_keywords = list(working_front.get("keywords") or [])
|
| 2560 |
_, remaining_front_rows = self._repair_validate_study_fact_contract(
|
| 2561 |
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 2562 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2563 |
if remaining_front_rows:
|
| 2564 |
raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining_front_rows[:3]}")
|
| 2565 |
quality_diagnostics.append({
|
|
@@ -2757,6 +2824,18 @@ class GenerationRunner:
|
|
| 2757 |
research_logic_contract = str(
|
| 2758 |
state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
|
| 2759 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2760 |
citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
|
| 2761 |
citation_minimums = dict(state.get("citation_minimums") or {})
|
| 2762 |
citation_reserve_targets = dict(state.get("citation_reserve_targets") or {})
|
|
@@ -2798,20 +2877,37 @@ class GenerationRunner:
|
|
| 2798 |
working_abstract = str(state.get("working_abstract") or "").strip()
|
| 2799 |
working_keywords = list(state.get("working_keywords") or [])
|
| 2800 |
if working_abstract and not state.get("english_finalized"):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2801 |
_, resumed_front_rows = self._repair_validate_study_fact_contract(
|
| 2802 |
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 2803 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2804 |
if resumed_front_rows:
|
| 2805 |
feedback = self._section_retry_feedback(resumed_front_rows)
|
| 2806 |
working_front = agent.generate_working_front_matter(
|
| 2807 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 2808 |
data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
|
|
|
|
| 2809 |
)
|
| 2810 |
candidate_abstract = str(working_front.get("abstract") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2811 |
candidate_keywords = list(working_front.get("keywords") or [])
|
| 2812 |
_, remaining = self._repair_validate_study_fact_contract(
|
| 2813 |
mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
|
| 2814 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2815 |
if remaining:
|
| 2816 |
raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
|
| 2817 |
working_abstract = candidate_abstract
|
|
@@ -3010,25 +3106,43 @@ class GenerationRunner:
|
|
| 3010 |
working_front = agent.generate_working_front_matter(
|
| 3011 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 3012 |
data_evidence=planning_data_md, mode=mode,
|
|
|
|
| 3013 |
)
|
| 3014 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3015 |
working_keywords = list(working_front.get("keywords") or [])
|
| 3016 |
if not working_abstract or not working_keywords:
|
| 3017 |
raise RuntimeError("working_front_matter_missing")
|
| 3018 |
_, front_fact_rows = self._repair_validate_study_fact_contract(
|
| 3019 |
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 3020 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3021 |
if front_fact_rows:
|
| 3022 |
feedback = self._section_retry_feedback(front_fact_rows)
|
| 3023 |
working_front = agent.generate_working_front_matter(
|
| 3024 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 3025 |
data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
|
|
|
|
| 3026 |
)
|
| 3027 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3028 |
working_keywords = list(working_front.get("keywords") or [])
|
| 3029 |
_, remaining = self._repair_validate_study_fact_contract(
|
| 3030 |
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 3031 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3032 |
if remaining:
|
| 3033 |
raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
|
| 3034 |
quality_diagnostics.append({
|
|
@@ -3414,6 +3528,16 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3414 |
en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
|
| 3415 |
en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
|
| 3416 |
en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3417 |
if mode == "asq":
|
| 3418 |
# Model prose has no authority over quote presence/count.
|
| 3419 |
en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
|
|
@@ -3433,9 +3557,13 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3433 |
# authoritative for ZH/RU/KK translation.
|
| 3434 |
self._progress(pct, f"citation_audit:{current_index}:{total_steps}")
|
| 3435 |
used_before = set(agent.used_citations)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3436 |
en_resolved = agent.audit_and_resolve_citations(
|
| 3437 |
prefix + en_raw, section_name=display_name, section_id=section_id,
|
| 3438 |
-
quarantine=is_quarantined
|
| 3439 |
)
|
| 3440 |
# Capture immediately: later recovery may replace the agent's last-
|
| 3441 |
# violation buffer. Only citation-owned rejected tags may enter the
|
|
@@ -3507,6 +3635,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3507 |
missing_items, section_name=display_name, section_id=section_id,
|
| 3508 |
section_context=en_resolved, needed=needed,
|
| 3509 |
excluded_tags=used_after | retry_excluded_tags,
|
|
|
|
| 3510 |
)
|
| 3511 |
# Evidence retrieval is permitted only on the first writer
|
| 3512 |
# attempt. Attempt 2 is bound to the original frozen packet.
|
|
@@ -3919,9 +4048,14 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3919 |
# front matter deterministically. No body rebalance may occur afterward.
|
| 3920 |
self._progress(92, "final_front_matter")
|
| 3921 |
final_front = agent.generate_final_front_matter(
|
| 3922 |
-
topic, keywords, body_only, data_evidence=user_data_md, mode=mode
|
|
|
|
| 3923 |
)
|
| 3924 |
final_abstract = str(final_front.get("abstract") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3925 |
final_keywords = list(final_front.get("keywords") or [])
|
| 3926 |
if not final_abstract or not final_keywords:
|
| 3927 |
raise RuntimeError("final_front_matter_missing")
|
|
@@ -3929,6 +4063,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3929 |
mode, agent, quant_engine, final_abstract, data_metadata,
|
| 3930 |
section_name="Final Abstract",
|
| 3931 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3932 |
if final_front_fact_rows:
|
| 3933 |
feedback = self._section_retry_feedback(final_front_fact_rows)
|
| 3934 |
logging.warning(
|
|
@@ -3938,9 +4076,13 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3938 |
)
|
| 3939 |
final_front = agent.generate_final_front_matter(
|
| 3940 |
topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
|
| 3941 |
-
validation_feedback=feedback,
|
| 3942 |
)
|
| 3943 |
final_abstract = str(final_front.get("abstract") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3944 |
final_keywords = list(final_front.get("keywords") or [])
|
| 3945 |
if not final_abstract or not final_keywords:
|
| 3946 |
raise RuntimeError("final_front_matter_missing_after_retry")
|
|
@@ -3948,6 +4090,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3948 |
mode, agent, quant_engine, final_abstract, data_metadata,
|
| 3949 |
section_name="Final Abstract",
|
| 3950 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3951 |
if remaining_final_fact_rows:
|
| 3952 |
state["final_abstract_retry_exhausted"] = {
|
| 3953 |
"attempts": 2,
|
|
@@ -3973,6 +4119,14 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3973 |
state.pop("final_abstract_retry_exhausted", None)
|
| 3974 |
full_draft_en = replace_abstract(full_draft_en, final_abstract)
|
| 3975 |
full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3976 |
body_only, _reference_tail = agent.validate_reference_tail(
|
| 3977 |
full_draft_en, minimum_references=final_reference_count
|
| 3978 |
)
|
|
|
|
| 1047 |
"or change the construct pairing to fit observed results. The contract is result-independent."
|
| 1048 |
)
|
| 1049 |
|
| 1050 |
+
@staticmethod
|
| 1051 |
+
def _hypothesis_memory_text(ledger: dict) -> str:
|
| 1052 |
+
"""Render the sole AMJ H1-Hn memory from the frozen ledger."""
|
| 1053 |
+
lines = []
|
| 1054 |
+
for item in (dict(ledger or {}).get("items") or []):
|
| 1055 |
+
if not isinstance(item, dict):
|
| 1056 |
+
continue
|
| 1057 |
+
hid = str(item.get("hypothesis_id") or "").strip().upper()
|
| 1058 |
+
predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
|
| 1059 |
+
outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
|
| 1060 |
+
if hid and predictor and outcome:
|
| 1061 |
+
lines.append(f"{hid}: {predictor} is associated with {outcome}.")
|
| 1062 |
+
return "\n".join(lines)
|
| 1063 |
+
|
| 1064 |
+
@staticmethod
|
| 1065 |
+
def _normalize_hypothesis_ordinal_aliases(text: str, ledger: dict) -> str:
|
| 1066 |
+
"""Map safe ordinal aliases to frozen H-identifiers without semantic rewrite."""
|
| 1067 |
+
value = str(text or "")
|
| 1068 |
+
items = [item for item in (dict(ledger or {}).get("items") or []) if isinstance(item, dict)]
|
| 1069 |
+
if not items:
|
| 1070 |
+
return value
|
| 1071 |
+
words = ["first", "second", "third", "fourth", "fifth", "sixth"]
|
| 1072 |
+
for index, item in enumerate(items[: len(words)]):
|
| 1073 |
+
hid = str(item.get("hypothesis_id") or "").strip().upper()
|
| 1074 |
+
if not hid:
|
| 1075 |
+
continue
|
| 1076 |
+
word = words[index]
|
| 1077 |
+
value = re.sub(rf"\b(?:the\s+)?{word}\s+hypothesis\b", hid, value, flags=re.I)
|
| 1078 |
+
return value
|
| 1079 |
+
|
| 1080 |
@classmethod
|
| 1081 |
def _hypothesis_contract_violations(
|
| 1082 |
cls, text: str, ledger: dict, *, section_name: str = "", require_all: bool = False
|
|
|
|
| 1111 |
"reason": "Ordinal hypothesis aliases can silently reorder the canonical H1-Hn identity.",
|
| 1112 |
"repair_instruction": "Refer to hypotheses only by their frozen H1-Hn identifiers.",
|
| 1113 |
})
|
| 1114 |
+
all_constructs = {
|
| 1115 |
+
re.sub(r"\s+", " ", str(item.get(key) or "")).strip()
|
| 1116 |
+
for item in items for key in ("predictor", "outcome")
|
| 1117 |
+
if str(item.get(key) or "").strip()
|
| 1118 |
+
}
|
| 1119 |
+
for start, end, sentence in SSciPipelineAgent._sentence_spans(value):
|
| 1120 |
+
ids = list(dict.fromkeys(
|
| 1121 |
+
f"H{n}" for n in re.findall(r"\bH(\d{1,2})\b", sentence, flags=re.I)
|
| 1122 |
+
))
|
| 1123 |
+
# Collective references such as "H1-H3 are summarized below"
|
| 1124 |
+
# are not definitions and must not be mistaken for remapping.
|
| 1125 |
+
if len(ids) != 1:
|
| 1126 |
+
continue
|
| 1127 |
+
hid = ids[0]
|
| 1128 |
+
item = expected.get(hid)
|
| 1129 |
+
if not item:
|
| 1130 |
+
continue
|
| 1131 |
+
predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
|
| 1132 |
+
outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
|
| 1133 |
+
lower_sentence = re.sub(r"\s+", " ", sentence).casefold()
|
| 1134 |
+
mentioned_constructs = {
|
| 1135 |
+
term for term in all_constructs if term.casefold() in lower_sentence
|
| 1136 |
}
|
| 1137 |
+
# Pairing identity is global, not limited to the theory-definition
|
| 1138 |
+
# sections. Require at least two named constructs so a bare reference
|
| 1139 |
+
# such as "H1 was supported" cannot be misclassified as remapping.
|
| 1140 |
+
if len(mentioned_constructs) >= 2 and predictor and outcome and (
|
| 1141 |
+
predictor.casefold() not in lower_sentence or outcome.casefold() not in lower_sentence
|
| 1142 |
+
):
|
| 1143 |
+
rows.append({
|
| 1144 |
+
"type": "hypothesis_contract_pairing_mismatch",
|
| 1145 |
+
"id": hid,
|
| 1146 |
+
"sentence": re.sub(r"\s+", " ", sentence).strip()[:420],
|
| 1147 |
+
"expected": f"{predictor} -> {outcome}",
|
| 1148 |
+
"reason": "A canonical hypothesis label was attached to a different construct pairing.",
|
| 1149 |
+
"repair_instruction": f"Define {hid} only for {predictor} and {outcome}, using association language.",
|
| 1150 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1151 |
if require_all:
|
| 1152 |
missing = sorted(set(expected) - observed)
|
| 1153 |
if missing:
|
|
|
|
| 2476 |
)
|
| 2477 |
state["data_metadata"] = data_metadata
|
| 2478 |
|
| 2479 |
+
hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {}) if mode == "amj" else {}
|
| 2480 |
+
hypothesis_contract = self._hypothesis_contract_directive(hypothesis_ledger)
|
| 2481 |
+
if hypothesis_ledger:
|
| 2482 |
+
# C5.1 single authority: legacy prose extraction may not redefine AMJ
|
| 2483 |
+
# H1-Hn once the result-independent ledger exists. Downstream memory is
|
| 2484 |
+
# populated deterministically from that ledger and survives checkpoints.
|
| 2485 |
+
agent.global_memory["hypotheses"] = self._hypothesis_memory_text(hypothesis_ledger)
|
| 2486 |
+
agent.global_memory["hypothesis_contract_locked"] = True
|
| 2487 |
+
|
| 2488 |
self._qualitative_quote_sources = (
|
| 2489 |
self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
|
| 2490 |
)
|
|
|
|
| 2559 |
agent.stream_generate_research_logic(
|
| 2560 |
topic, keywords, synthesis_result, refs_text,
|
| 2561 |
data_evidence=planning_data_md, mode=mode,
|
| 2562 |
+
hypothesis_contract=hypothesis_contract,
|
| 2563 |
)
|
| 2564 |
)
|
| 2565 |
agent.record_prewrite_stage("research_logic", prewrite_started, prewrite_calls)
|
| 2566 |
research_logic_contract = re.sub(
|
| 2567 |
r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
|
| 2568 |
).strip()
|
| 2569 |
+
if hypothesis_ledger:
|
| 2570 |
+
research_logic_contract = self._normalize_hypothesis_ordinal_aliases(
|
| 2571 |
+
research_logic_contract, hypothesis_ledger
|
| 2572 |
+
)
|
| 2573 |
+
logic_hypothesis_rows = self._hypothesis_contract_violations(
|
| 2574 |
+
research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
|
| 2575 |
+
)
|
| 2576 |
+
if logic_hypothesis_rows:
|
| 2577 |
+
raise RuntimeError(
|
| 2578 |
+
f"research_logic_hypothesis_contract_invalid:{logic_hypothesis_rows[:3]}"
|
| 2579 |
+
)
|
| 2580 |
if not research_logic_contract:
|
| 2581 |
raise RuntimeError("research_logic_contract_missing")
|
| 2582 |
agent.global_memory["research_logic_contract"] = research_logic_contract
|
|
|
|
| 2585 |
working_front = agent.generate_working_front_matter(
|
| 2586 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 2587 |
data_evidence=planning_data_md, mode=mode,
|
| 2588 |
+
hypothesis_contract=hypothesis_contract,
|
| 2589 |
)
|
| 2590 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
| 2591 |
+
if hypothesis_ledger:
|
| 2592 |
+
working_abstract = self._normalize_hypothesis_ordinal_aliases(
|
| 2593 |
+
working_abstract, hypothesis_ledger
|
| 2594 |
+
)
|
| 2595 |
working_keywords = list(working_front.get("keywords") or [])
|
| 2596 |
if not working_abstract or not working_keywords:
|
| 2597 |
raise RuntimeError("working_front_matter_missing")
|
|
|
|
| 2599 |
mode, agent, quant_engine, working_abstract, data_metadata,
|
| 2600 |
section_name="Working Abstract",
|
| 2601 |
)
|
| 2602 |
+
if hypothesis_ledger:
|
| 2603 |
+
front_fact_rows.extend(self._hypothesis_contract_violations(
|
| 2604 |
+
working_abstract, hypothesis_ledger, section_name="Working Abstract"
|
| 2605 |
+
))
|
| 2606 |
if front_fact_rows:
|
| 2607 |
feedback = self._section_retry_feedback(front_fact_rows)
|
| 2608 |
logging.warning(
|
|
|
|
| 2612 |
working_front = agent.generate_working_front_matter(
|
| 2613 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 2614 |
data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
|
| 2615 |
+
hypothesis_contract=hypothesis_contract,
|
| 2616 |
)
|
| 2617 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
| 2618 |
+
if hypothesis_ledger:
|
| 2619 |
+
working_abstract = self._normalize_hypothesis_ordinal_aliases(
|
| 2620 |
+
working_abstract, hypothesis_ledger
|
| 2621 |
+
)
|
| 2622 |
working_keywords = list(working_front.get("keywords") or [])
|
| 2623 |
_, remaining_front_rows = self._repair_validate_study_fact_contract(
|
| 2624 |
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 2625 |
)
|
| 2626 |
+
if hypothesis_ledger:
|
| 2627 |
+
remaining_front_rows.extend(self._hypothesis_contract_violations(
|
| 2628 |
+
working_abstract, hypothesis_ledger, section_name="Working Abstract"
|
| 2629 |
+
))
|
| 2630 |
if remaining_front_rows:
|
| 2631 |
raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining_front_rows[:3]}")
|
| 2632 |
quality_diagnostics.append({
|
|
|
|
| 2824 |
research_logic_contract = str(
|
| 2825 |
state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
|
| 2826 |
)
|
| 2827 |
+
if hypothesis_ledger and research_logic_contract:
|
| 2828 |
+
research_logic_contract = self._normalize_hypothesis_ordinal_aliases(
|
| 2829 |
+
research_logic_contract, hypothesis_ledger
|
| 2830 |
+
)
|
| 2831 |
+
resumed_logic_rows = self._hypothesis_contract_violations(
|
| 2832 |
+
research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
|
| 2833 |
+
)
|
| 2834 |
+
if resumed_logic_rows:
|
| 2835 |
+
raise RuntimeError(
|
| 2836 |
+
f"research_logic_hypothesis_contract_invalid_on_resume:{resumed_logic_rows[:3]}"
|
| 2837 |
+
)
|
| 2838 |
+
agent.global_memory["research_logic_contract"] = research_logic_contract
|
| 2839 |
citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
|
| 2840 |
citation_minimums = dict(state.get("citation_minimums") or {})
|
| 2841 |
citation_reserve_targets = dict(state.get("citation_reserve_targets") or {})
|
|
|
|
| 2877 |
working_abstract = str(state.get("working_abstract") or "").strip()
|
| 2878 |
working_keywords = list(state.get("working_keywords") or [])
|
| 2879 |
if working_abstract and not state.get("english_finalized"):
|
| 2880 |
+
if hypothesis_ledger:
|
| 2881 |
+
working_abstract = self._normalize_hypothesis_ordinal_aliases(
|
| 2882 |
+
working_abstract, hypothesis_ledger
|
| 2883 |
+
)
|
| 2884 |
_, resumed_front_rows = self._repair_validate_study_fact_contract(
|
| 2885 |
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 2886 |
)
|
| 2887 |
+
if hypothesis_ledger:
|
| 2888 |
+
resumed_front_rows.extend(self._hypothesis_contract_violations(
|
| 2889 |
+
working_abstract, hypothesis_ledger, section_name="Working Abstract"
|
| 2890 |
+
))
|
| 2891 |
if resumed_front_rows:
|
| 2892 |
feedback = self._section_retry_feedback(resumed_front_rows)
|
| 2893 |
working_front = agent.generate_working_front_matter(
|
| 2894 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 2895 |
data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
|
| 2896 |
+
hypothesis_contract=hypothesis_contract,
|
| 2897 |
)
|
| 2898 |
candidate_abstract = str(working_front.get("abstract") or "").strip()
|
| 2899 |
+
if hypothesis_ledger:
|
| 2900 |
+
candidate_abstract = self._normalize_hypothesis_ordinal_aliases(
|
| 2901 |
+
candidate_abstract, hypothesis_ledger
|
| 2902 |
+
)
|
| 2903 |
candidate_keywords = list(working_front.get("keywords") or [])
|
| 2904 |
_, remaining = self._repair_validate_study_fact_contract(
|
| 2905 |
mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
|
| 2906 |
)
|
| 2907 |
+
if hypothesis_ledger:
|
| 2908 |
+
remaining.extend(self._hypothesis_contract_violations(
|
| 2909 |
+
candidate_abstract, hypothesis_ledger, section_name="Working Abstract"
|
| 2910 |
+
))
|
| 2911 |
if remaining:
|
| 2912 |
raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
|
| 2913 |
working_abstract = candidate_abstract
|
|
|
|
| 3106 |
working_front = agent.generate_working_front_matter(
|
| 3107 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 3108 |
data_evidence=planning_data_md, mode=mode,
|
| 3109 |
+
hypothesis_contract=hypothesis_contract,
|
| 3110 |
)
|
| 3111 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
| 3112 |
+
if hypothesis_ledger:
|
| 3113 |
+
working_abstract = self._normalize_hypothesis_ordinal_aliases(
|
| 3114 |
+
working_abstract, hypothesis_ledger
|
| 3115 |
+
)
|
| 3116 |
working_keywords = list(working_front.get("keywords") or [])
|
| 3117 |
if not working_abstract or not working_keywords:
|
| 3118 |
raise RuntimeError("working_front_matter_missing")
|
| 3119 |
_, front_fact_rows = self._repair_validate_study_fact_contract(
|
| 3120 |
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 3121 |
)
|
| 3122 |
+
if hypothesis_ledger:
|
| 3123 |
+
front_fact_rows.extend(self._hypothesis_contract_violations(
|
| 3124 |
+
working_abstract, hypothesis_ledger, section_name="Working Abstract"
|
| 3125 |
+
))
|
| 3126 |
if front_fact_rows:
|
| 3127 |
feedback = self._section_retry_feedback(front_fact_rows)
|
| 3128 |
working_front = agent.generate_working_front_matter(
|
| 3129 |
topic, keywords, research_logic_contract, synthesis_result,
|
| 3130 |
data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
|
| 3131 |
+
hypothesis_contract=hypothesis_contract,
|
| 3132 |
)
|
| 3133 |
working_abstract = str(working_front.get("abstract") or "").strip()
|
| 3134 |
+
if hypothesis_ledger:
|
| 3135 |
+
working_abstract = self._normalize_hypothesis_ordinal_aliases(
|
| 3136 |
+
working_abstract, hypothesis_ledger
|
| 3137 |
+
)
|
| 3138 |
working_keywords = list(working_front.get("keywords") or [])
|
| 3139 |
_, remaining = self._repair_validate_study_fact_contract(
|
| 3140 |
mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
|
| 3141 |
)
|
| 3142 |
+
if hypothesis_ledger:
|
| 3143 |
+
remaining.extend(self._hypothesis_contract_violations(
|
| 3144 |
+
working_abstract, hypothesis_ledger, section_name="Working Abstract"
|
| 3145 |
+
))
|
| 3146 |
if remaining:
|
| 3147 |
raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
|
| 3148 |
quality_diagnostics.append({
|
|
|
|
| 3528 |
en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
|
| 3529 |
en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
|
| 3530 |
en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
|
| 3531 |
+
if mode == "amj" and hypothesis_ledger:
|
| 3532 |
+
normalized_hypothesis_text = self._normalize_hypothesis_ordinal_aliases(
|
| 3533 |
+
en_raw, hypothesis_ledger
|
| 3534 |
+
)
|
| 3535 |
+
if normalized_hypothesis_text != en_raw:
|
| 3536 |
+
logging.info(
|
| 3537 |
+
"Deterministic hypothesis ordinal alias normalization | unit=%s",
|
| 3538 |
+
display_name,
|
| 3539 |
+
)
|
| 3540 |
+
en_raw = normalized_hypothesis_text
|
| 3541 |
if mode == "asq":
|
| 3542 |
# Model prose has no authority over quote presence/count.
|
| 3543 |
en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
|
|
|
|
| 3557 |
# authoritative for ZH/RU/KK translation.
|
| 3558 |
self._progress(pct, f"citation_audit:{current_index}:{total_steps}")
|
| 3559 |
used_before = set(agent.used_citations)
|
| 3560 |
+
allowed_section_citation_tags = (
|
| 3561 |
+
set(attempt_assigned_tag_list) - set(retry_excluded_tags)
|
| 3562 |
+
if not is_quarantined else set()
|
| 3563 |
+
)
|
| 3564 |
en_resolved = agent.audit_and_resolve_citations(
|
| 3565 |
prefix + en_raw, section_name=display_name, section_id=section_id,
|
| 3566 |
+
quarantine=is_quarantined, allowed_tags=allowed_section_citation_tags
|
| 3567 |
)
|
| 3568 |
# Capture immediately: later recovery may replace the agent's last-
|
| 3569 |
# violation buffer. Only citation-owned rejected tags may enter the
|
|
|
|
| 3635 |
missing_items, section_name=display_name, section_id=section_id,
|
| 3636 |
section_context=en_resolved, needed=needed,
|
| 3637 |
excluded_tags=used_after | retry_excluded_tags,
|
| 3638 |
+
allowed_tags=set(attempt_assigned_tag_list),
|
| 3639 |
)
|
| 3640 |
# Evidence retrieval is permitted only on the first writer
|
| 3641 |
# attempt. Attempt 2 is bound to the original frozen packet.
|
|
|
|
| 4048 |
# front matter deterministically. No body rebalance may occur afterward.
|
| 4049 |
self._progress(92, "final_front_matter")
|
| 4050 |
final_front = agent.generate_final_front_matter(
|
| 4051 |
+
topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
|
| 4052 |
+
hypothesis_contract=hypothesis_contract,
|
| 4053 |
)
|
| 4054 |
final_abstract = str(final_front.get("abstract") or "").strip()
|
| 4055 |
+
if hypothesis_ledger:
|
| 4056 |
+
final_abstract = self._normalize_hypothesis_ordinal_aliases(
|
| 4057 |
+
final_abstract, hypothesis_ledger
|
| 4058 |
+
)
|
| 4059 |
final_keywords = list(final_front.get("keywords") or [])
|
| 4060 |
if not final_abstract or not final_keywords:
|
| 4061 |
raise RuntimeError("final_front_matter_missing")
|
|
|
|
| 4063 |
mode, agent, quant_engine, final_abstract, data_metadata,
|
| 4064 |
section_name="Final Abstract",
|
| 4065 |
)
|
| 4066 |
+
if hypothesis_ledger:
|
| 4067 |
+
final_front_fact_rows.extend(self._hypothesis_contract_violations(
|
| 4068 |
+
final_abstract, hypothesis_ledger, section_name="Final Abstract"
|
| 4069 |
+
))
|
| 4070 |
if final_front_fact_rows:
|
| 4071 |
feedback = self._section_retry_feedback(final_front_fact_rows)
|
| 4072 |
logging.warning(
|
|
|
|
| 4076 |
)
|
| 4077 |
final_front = agent.generate_final_front_matter(
|
| 4078 |
topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
|
| 4079 |
+
validation_feedback=feedback, hypothesis_contract=hypothesis_contract,
|
| 4080 |
)
|
| 4081 |
final_abstract = str(final_front.get("abstract") or "").strip()
|
| 4082 |
+
if hypothesis_ledger:
|
| 4083 |
+
final_abstract = self._normalize_hypothesis_ordinal_aliases(
|
| 4084 |
+
final_abstract, hypothesis_ledger
|
| 4085 |
+
)
|
| 4086 |
final_keywords = list(final_front.get("keywords") or [])
|
| 4087 |
if not final_abstract or not final_keywords:
|
| 4088 |
raise RuntimeError("final_front_matter_missing_after_retry")
|
|
|
|
| 4090 |
mode, agent, quant_engine, final_abstract, data_metadata,
|
| 4091 |
section_name="Final Abstract",
|
| 4092 |
)
|
| 4093 |
+
if hypothesis_ledger:
|
| 4094 |
+
remaining_final_fact_rows.extend(self._hypothesis_contract_violations(
|
| 4095 |
+
final_abstract, hypothesis_ledger, section_name="Final Abstract"
|
| 4096 |
+
))
|
| 4097 |
if remaining_final_fact_rows:
|
| 4098 |
state["final_abstract_retry_exhausted"] = {
|
| 4099 |
"attempts": 2,
|
|
|
|
| 4119 |
state.pop("final_abstract_retry_exhausted", None)
|
| 4120 |
full_draft_en = replace_abstract(full_draft_en, final_abstract)
|
| 4121 |
full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
|
| 4122 |
+
if mode == "amj" and hypothesis_ledger:
|
| 4123 |
+
post_front_hypothesis_rows = self._hypothesis_contract_violations(
|
| 4124 |
+
full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
|
| 4125 |
+
)
|
| 4126 |
+
if post_front_hypothesis_rows:
|
| 4127 |
+
raise RuntimeError(
|
| 4128 |
+
f"final_hypothesis_contract_failed_after_front_matter:{post_front_hypothesis_rows[:3]}"
|
| 4129 |
+
)
|
| 4130 |
body_only, _reference_tail = agent.validate_reference_tail(
|
| 4131 |
full_draft_en, minimum_references=final_reference_count
|
| 4132 |
)
|
core/pipeline_agent.py
CHANGED
|
@@ -135,6 +135,10 @@ class SSciPipelineAgent:
|
|
| 135 |
self.s2_fused = False
|
| 136 |
self.oa_fused = False
|
| 137 |
self.citation_map = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
self.used_citations = set()
|
| 139 |
self.citation_audit_log = []
|
| 140 |
# R3.2.8.6: translation validators emit hierarchical assurance evidence.
|
|
@@ -195,7 +199,7 @@ class SSciPipelineAgent:
|
|
| 195 |
self.global_memory = {
|
| 196 |
"hypotheses": "", "variables": "", "model_summary": "",
|
| 197 |
"method_summary": "", "research_logic_contract": "",
|
| 198 |
-
"core_golden_metadata": []
|
| 199 |
}
|
| 200 |
|
| 201 |
try:
|
|
@@ -311,6 +315,7 @@ class SSciPipelineAgent:
|
|
| 311 |
"s2_calls": self.s2_calls,
|
| 312 |
"source_metrics": self.get_source_metrics(),
|
| 313 |
"citation_map": self.citation_map,
|
|
|
|
| 314 |
"used_citations": list(self.used_citations),
|
| 315 |
"citation_audit_log": list(self.citation_audit_log),
|
| 316 |
"translation_assurance_log": list(self.translation_assurance_log),
|
|
@@ -334,6 +339,10 @@ class SSciPipelineAgent:
|
|
| 334 |
if isinstance(loaded_metrics.get(source), dict):
|
| 335 |
self.source_metrics[source].update(loaded_metrics[source])
|
| 336 |
self.citation_map = state_dict.get("citation_map", {})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
self.used_citations = set(state_dict.get("used_citations", []))
|
| 338 |
self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
|
| 339 |
self.translation_assurance_log = list(state_dict.get("translation_assurance_log", []))
|
|
@@ -456,7 +465,10 @@ class SSciPipelineAgent:
|
|
| 456 |
|
| 457 |
def _extract_memory_from_text(self, text, section_name):
|
| 458 |
lower_sec = section_name.lower()
|
| 459 |
-
if
|
|
|
|
|
|
|
|
|
|
| 460 |
hypo_matches = re.findall(r'(H\d+[:\s].*?)(?=\n|$)', text, re.IGNORECASE)
|
| 461 |
if hypo_matches:
|
| 462 |
self.global_memory["hypotheses"] = "\n".join(hypo_matches[:10])
|
|
@@ -1274,6 +1286,7 @@ class SSciPipelineAgent:
|
|
| 1274 |
refs_text,
|
| 1275 |
data_evidence="",
|
| 1276 |
mode="amj",
|
|
|
|
| 1277 |
):
|
| 1278 |
"""Generate a provisional research-logic contract without prewriting results."""
|
| 1279 |
mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
|
|
@@ -1296,6 +1309,10 @@ Literature Synthesis:
|
|
| 1296 |
|
| 1297 |
{evidence_block}
|
| 1298 |
{mode_directive}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1299 |
Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
|
| 1300 |
def _stream_direct_research_logic():
|
| 1301 |
# R4-A: research logic is a concise planning contract. A reasoning-first
|
|
@@ -1469,7 +1486,7 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
|
|
| 1469 |
raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
|
| 1470 |
|
| 1471 |
def generate_working_front_matter(
|
| 1472 |
-
self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback=''
|
| 1473 |
):
|
| 1474 |
"""Create a provisional visible abstract/keyword anchor without prewriting results."""
|
| 1475 |
evidence = self._truncate_context(str(data_evidence or ''), 3500)
|
|
@@ -1490,6 +1507,10 @@ USER KEYWORD SEEDS: {keywords}
|
|
| 1490 |
<FROZEN_RESEARCH_EVIDENCE>
|
| 1491 |
{evidence}
|
| 1492 |
</FROZEN_RESEARCH_EVIDENCE>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1493 |
<LOCAL_VALIDATION_FEEDBACK>
|
| 1494 |
{str(validation_feedback or '')[:2500]}
|
| 1495 |
</LOCAL_VALIDATION_FEEDBACK>"""
|
|
@@ -1503,7 +1524,7 @@ USER KEYWORD SEEDS: {keywords}
|
|
| 1503 |
prompt, phase='Working', max_tokens=1100, validator=validate_working,
|
| 1504 |
)
|
| 1505 |
|
| 1506 |
-
def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj', validation_feedback=''):
|
| 1507 |
"""Generate final Abstract + Keywords only from the frozen completed body/evidence."""
|
| 1508 |
evidence = self._truncate_context(str(data_evidence or ''), 5000)
|
| 1509 |
body = self._abstract_manuscript_context(manuscript, 18000)
|
|
@@ -1516,6 +1537,10 @@ USER KEYWORD SEEDS: {keywords}
|
|
| 1516 |
<FROZEN_RESEARCH_EVIDENCE>
|
| 1517 |
{evidence}
|
| 1518 |
</FROZEN_RESEARCH_EVIDENCE>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1519 |
<COMPLETED_FROZEN_MANUSCRIPT>
|
| 1520 |
{body}
|
| 1521 |
</COMPLETED_FROZEN_MANUSCRIPT>
|
|
@@ -2450,6 +2475,7 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2450 |
"""
|
| 2451 |
with self.lock:
|
| 2452 |
self.citation_map.clear()
|
|
|
|
| 2453 |
self.used_citations.clear()
|
| 2454 |
self.citation_audit_log.clear()
|
| 2455 |
for source in ('s2', 'oa'):
|
|
@@ -2529,6 +2555,9 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2529 |
all_used_terms.add(term)
|
| 2530 |
if not normalized_terms:
|
| 2531 |
continue
|
|
|
|
|
|
|
|
|
|
| 2532 |
|
| 2533 |
run_wave(normalized_terms, second_page=False)
|
| 2534 |
if unique_preview_count() >= min_required:
|
|
@@ -2585,7 +2614,7 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2585 |
return int(match.group()) if match else 10**9
|
| 2586 |
|
| 2587 |
@staticmethod
|
| 2588 |
-
def
|
| 2589 |
stopwords = {
|
| 2590 |
"about", "after", "also", "among", "and", "are", "because",
|
| 2591 |
"been", "before", "between", "both", "can", "could", "does",
|
|
@@ -2596,7 +2625,11 @@ Return ONLY a valid JSON array of strings."""
|
|
| 2596 |
"with", "within", "would",
|
| 2597 |
}
|
| 2598 |
words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", str(value).lower())
|
| 2599 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2600 |
|
| 2601 |
def build_claim_evidence_plan(self, tasks, topic, synthesis_result, mode='amj', target_unique=60):
|
| 2602 |
"""Build a bounded citation-first plan without one oversized model call.
|
|
@@ -2810,6 +2843,17 @@ INPUT:
|
|
| 2810 |
'use','uses','used','using','user','users','approach','approaches','framework','frameworks',
|
| 2811 |
})
|
| 2812 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2813 |
@classmethod
|
| 2814 |
def _citation_recovery_overlap(cls, section_name, section_context, claim, evidence):
|
| 2815 |
anchor_terms = cls._citation_terms(f"{section_name} {section_context}")
|
|
@@ -2876,6 +2920,7 @@ INPUT:
|
|
| 2876 |
|
| 2877 |
def select_contextual_citation_recovery_items(
|
| 2878 |
self, items, *, section_name='', section_id='', section_context='', needed=1, excluded_tags=None,
|
|
|
|
| 2879 |
):
|
| 2880 |
"""Select only recovery citations that are actually about the current unit.
|
| 2881 |
|
|
@@ -2888,6 +2933,9 @@ INPUT:
|
|
| 2888 |
if needed <= 0:
|
| 2889 |
return []
|
| 2890 |
excluded = {str(tag or '').strip() for tag in (excluded_tags or set()) if str(tag or '').strip()}
|
|
|
|
|
|
|
|
|
|
| 2891 |
ranked = []
|
| 2892 |
seen = set()
|
| 2893 |
for item in items or []:
|
|
@@ -2896,6 +2944,8 @@ INPUT:
|
|
| 2896 |
tag = str(item.get('tag') or '').strip()
|
| 2897 |
if not tag or tag in excluded or tag in seen:
|
| 2898 |
continue
|
|
|
|
|
|
|
| 2899 |
if not self.citation_source_role_eligible(
|
| 2900 |
tag, section_id=section_id, section_name=section_name
|
| 2901 |
):
|
|
@@ -2917,6 +2967,8 @@ INPUT:
|
|
| 2917 |
for tag, meta in self.citation_map.items():
|
| 2918 |
if tag in used:
|
| 2919 |
continue
|
|
|
|
|
|
|
| 2920 |
if not self.citation_source_role_eligible(
|
| 2921 |
tag, metadata=meta, section_id=section_id, section_name=section_name
|
| 2922 |
):
|
|
@@ -2941,30 +2993,80 @@ INPUT:
|
|
| 2941 |
return selected
|
| 2942 |
|
| 2943 |
def _deterministic_contextual_jit_query(self, topic, section_name, section_context):
|
| 2944 |
-
"""Build one bounded
|
| 2945 |
-
|
| 2946 |
-
|
| 2947 |
-
|
| 2948 |
-
|
| 2949 |
-
|
| 2950 |
-
|
| 2951 |
-
|
| 2952 |
-
|
| 2953 |
-
|
| 2954 |
-
|
| 2955 |
-
|
| 2956 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2957 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2958 |
selected = []
|
| 2959 |
-
#
|
| 2960 |
-
#
|
| 2961 |
-
|
| 2962 |
-
|
| 2963 |
-
if
|
| 2964 |
-
selected.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2965 |
if len(selected) >= 5:
|
| 2966 |
break
|
| 2967 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2968 |
|
| 2969 |
def fetch_contextual_jit_recovery_items(
|
| 2970 |
self, *, topic, section_name, section_context, section_id='', needed=1, excluded_tags=None,
|
|
@@ -3248,6 +3350,15 @@ INPUT:
|
|
| 3248 |
supports_section_id = True
|
| 3249 |
if supports_section_id:
|
| 3250 |
audit_kwargs['section_id'] = section_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3251 |
audited = audit_method(proposed, **audit_kwargs)
|
| 3252 |
except PipelineCancelledError:
|
| 3253 |
raise
|
|
@@ -3294,7 +3405,7 @@ INPUT:
|
|
| 3294 |
return ''
|
| 3295 |
|
| 3296 |
def _candidate_citation_tags(
|
| 3297 |
-
self, sentence, required_tags, limit, *, section_id='', section_name=''
|
| 3298 |
):
|
| 3299 |
"""Return source-role-eligible semantic-audit candidates for one claim.
|
| 3300 |
|
|
@@ -3304,9 +3415,14 @@ INPUT:
|
|
| 3304 |
them. Lexical overlap only ranks eligible candidates; it never authorizes
|
| 3305 |
attachment by itself.
|
| 3306 |
"""
|
|
|
|
|
|
|
|
|
|
| 3307 |
valid_required = [
|
| 3308 |
tag for tag in required_tags
|
| 3309 |
-
if tag in self.citation_map
|
|
|
|
|
|
|
| 3310 |
tag, section_id=section_id, section_name=section_name
|
| 3311 |
)
|
| 3312 |
]
|
|
@@ -3315,6 +3431,8 @@ INPUT:
|
|
| 3315 |
for tag, metadata in self.citation_map.items():
|
| 3316 |
if tag in valid_required:
|
| 3317 |
continue
|
|
|
|
|
|
|
| 3318 |
if not self.citation_source_role_eligible(
|
| 3319 |
tag, metadata=metadata, section_id=section_id, section_name=section_name
|
| 3320 |
):
|
|
@@ -4170,11 +4288,68 @@ INPUT:
|
|
| 4170 |
core = f"{core} {tag}{punctuation}".strip()
|
| 4171 |
return f"{leading}{core}{trailing}"
|
| 4172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4173 |
def semantic_audit_claim_specific_citations(
|
| 4174 |
self,
|
| 4175 |
text,
|
| 4176 |
section_name="",
|
| 4177 |
section_id="",
|
|
|
|
| 4178 |
):
|
| 4179 |
"""Audit citation support while granting the audit TAG-ONLY mutation authority.
|
| 4180 |
|
|
@@ -4218,7 +4393,7 @@ INPUT:
|
|
| 4218 |
sentence,
|
| 4219 |
required_tags,
|
| 4220 |
candidate_limit,
|
| 4221 |
-
section_id=section_id, section_name=section_name,
|
| 4222 |
)
|
| 4223 |
evidence = []
|
| 4224 |
for tag in candidates:
|
|
@@ -4391,23 +4566,30 @@ Return exactly:
|
|
| 4391 |
)
|
| 4392 |
continue
|
| 4393 |
if status == "split":
|
| 4394 |
-
#
|
| 4395 |
-
#
|
| 4396 |
-
#
|
| 4397 |
-
#
|
| 4398 |
-
#
|
| 4399 |
-
#
|
| 4400 |
if len(revised_tags) == 1:
|
| 4401 |
selected = next(iter(revised_tags))
|
| 4402 |
-
|
| 4403 |
-
record["original"], selected
|
| 4404 |
-
|
| 4405 |
-
|
| 4406 |
-
section_name, "normalize_inconsistent_split_to_single_source",
|
| 4407 |
-
record["original"], replacements[record["id"]],
|
| 4408 |
-
reason or "Audit returned split with only one eligible supporting source.",
|
| 4409 |
)
|
| 4410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4411 |
violation = {
|
| 4412 |
"type":"citation_claim_split_required", "id":record["id"],
|
| 4413 |
"sentence":record["original"][:500], "reason":reason,
|
|
@@ -4610,7 +4792,9 @@ Return exactly:
|
|
| 4610 |
)
|
| 4611 |
return revised
|
| 4612 |
|
| 4613 |
-
def audit_and_resolve_citations(
|
|
|
|
|
|
|
| 4614 |
self._last_citation_semantic_violations = []
|
| 4615 |
value = str(text or '')
|
| 4616 |
if quarantine:
|
|
@@ -4620,12 +4804,31 @@ Return exactly:
|
|
| 4620 |
for tag in unknown:
|
| 4621 |
value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
|
| 4622 |
self._append_citation_audit(section_name, 'remove_unknown_tags', text, value, ','.join(unknown))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4623 |
has_citations = bool(re.search(r'CITE-\d+', value, re.I))
|
| 4624 |
violations = self.find_multi_citation_violations(value)
|
| 4625 |
if has_citations and self.citation_semantic_audit.get('enabled', True):
|
| 4626 |
try:
|
| 4627 |
value = self.semantic_audit_claim_specific_citations(
|
| 4628 |
-
value, section_name=section_name, section_id=section_id
|
| 4629 |
)
|
| 4630 |
except PipelineCancelledError:
|
| 4631 |
raise
|
|
@@ -4650,6 +4853,10 @@ Return exactly:
|
|
| 4650 |
value = self._local_citation_recovery(value, section_name, retain_lexical=False)
|
| 4651 |
elif violations:
|
| 4652 |
value = self._local_citation_recovery(value, section_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4653 |
if self.find_multi_citation_violations(value):
|
| 4654 |
value = self._local_citation_recovery(value, section_name)
|
| 4655 |
if self.find_multi_citation_violations(value):
|
|
|
|
| 135 |
self.s2_fused = False
|
| 136 |
self.oa_fused = False
|
| 137 |
self.citation_map = {}
|
| 138 |
+
# C5.2: English retrieval anchors are captured from the actual initial
|
| 139 |
+
# literature-search queries so non-English user topics can reuse the same
|
| 140 |
+
# semantic topic vocabulary during bounded JIT recovery.
|
| 141 |
+
self.retrieval_query_anchors = []
|
| 142 |
self.used_citations = set()
|
| 143 |
self.citation_audit_log = []
|
| 144 |
# R3.2.8.6: translation validators emit hierarchical assurance evidence.
|
|
|
|
| 199 |
self.global_memory = {
|
| 200 |
"hypotheses": "", "variables": "", "model_summary": "",
|
| 201 |
"method_summary": "", "research_logic_contract": "",
|
| 202 |
+
"core_golden_metadata": [], "hypothesis_contract_locked": False,
|
| 203 |
}
|
| 204 |
|
| 205 |
try:
|
|
|
|
| 315 |
"s2_calls": self.s2_calls,
|
| 316 |
"source_metrics": self.get_source_metrics(),
|
| 317 |
"citation_map": self.citation_map,
|
| 318 |
+
"retrieval_query_anchors": list(getattr(self, "retrieval_query_anchors", [])),
|
| 319 |
"used_citations": list(self.used_citations),
|
| 320 |
"citation_audit_log": list(self.citation_audit_log),
|
| 321 |
"translation_assurance_log": list(self.translation_assurance_log),
|
|
|
|
| 339 |
if isinstance(loaded_metrics.get(source), dict):
|
| 340 |
self.source_metrics[source].update(loaded_metrics[source])
|
| 341 |
self.citation_map = state_dict.get("citation_map", {})
|
| 342 |
+
self.retrieval_query_anchors = [
|
| 343 |
+
str(value or '').strip() for value in state_dict.get("retrieval_query_anchors", [])
|
| 344 |
+
if str(value or '').strip()
|
| 345 |
+
]
|
| 346 |
self.used_citations = set(state_dict.get("used_citations", []))
|
| 347 |
self.citation_audit_log = list(state_dict.get("citation_audit_log", []))
|
| 348 |
self.translation_assurance_log = list(state_dict.get("translation_assurance_log", []))
|
|
|
|
| 465 |
|
| 466 |
def _extract_memory_from_text(self, text, section_name):
|
| 467 |
lower_sec = section_name.lower()
|
| 468 |
+
if (
|
| 469 |
+
not bool(self.global_memory.get("hypothesis_contract_locked"))
|
| 470 |
+
and any(kw in lower_sec for kw in ["introduction", "literature", "hypothesis", "framing"])
|
| 471 |
+
):
|
| 472 |
hypo_matches = re.findall(r'(H\d+[:\s].*?)(?=\n|$)', text, re.IGNORECASE)
|
| 473 |
if hypo_matches:
|
| 474 |
self.global_memory["hypotheses"] = "\n".join(hypo_matches[:10])
|
|
|
|
| 1286 |
refs_text,
|
| 1287 |
data_evidence="",
|
| 1288 |
mode="amj",
|
| 1289 |
+
hypothesis_contract="",
|
| 1290 |
):
|
| 1291 |
"""Generate a provisional research-logic contract without prewriting results."""
|
| 1292 |
mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
|
|
|
|
| 1309 |
|
| 1310 |
{evidence_block}
|
| 1311 |
{mode_directive}
|
| 1312 |
+
<IMMUTABLE_HYPOTHESIS_CONTRACT>
|
| 1313 |
+
{str(hypothesis_contract or '')}
|
| 1314 |
+
</IMMUTABLE_HYPOTHESIS_CONTRACT>
|
| 1315 |
+
If the immutable hypothesis contract is non-empty, it is the sole authority for H-identifiers and construct pairings. Do not redefine, reorder, or infer an alternative H1-Hn architecture.
|
| 1316 |
Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
|
| 1317 |
def _stream_direct_research_logic():
|
| 1318 |
# R4-A: research logic is a concise planning contract. A reasoning-first
|
|
|
|
| 1486 |
raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
|
| 1487 |
|
| 1488 |
def generate_working_front_matter(
|
| 1489 |
+
self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''
|
| 1490 |
):
|
| 1491 |
"""Create a provisional visible abstract/keyword anchor without prewriting results."""
|
| 1492 |
evidence = self._truncate_context(str(data_evidence or ''), 3500)
|
|
|
|
| 1507 |
<FROZEN_RESEARCH_EVIDENCE>
|
| 1508 |
{evidence}
|
| 1509 |
</FROZEN_RESEARCH_EVIDENCE>
|
| 1510 |
+
<IMMUTABLE_HYPOTHESIS_CONTRACT>
|
| 1511 |
+
{str(hypothesis_contract or '')}
|
| 1512 |
+
</IMMUTABLE_HYPOTHESIS_CONTRACT>
|
| 1513 |
+
If the immutable hypothesis contract is non-empty, preserve its H-identifiers and construct pairings exactly; do not invent or reorder hypotheses.
|
| 1514 |
<LOCAL_VALIDATION_FEEDBACK>
|
| 1515 |
{str(validation_feedback or '')[:2500]}
|
| 1516 |
</LOCAL_VALIDATION_FEEDBACK>"""
|
|
|
|
| 1524 |
prompt, phase='Working', max_tokens=1100, validator=validate_working,
|
| 1525 |
)
|
| 1526 |
|
| 1527 |
+
def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''):
|
| 1528 |
"""Generate final Abstract + Keywords only from the frozen completed body/evidence."""
|
| 1529 |
evidence = self._truncate_context(str(data_evidence or ''), 5000)
|
| 1530 |
body = self._abstract_manuscript_context(manuscript, 18000)
|
|
|
|
| 1537 |
<FROZEN_RESEARCH_EVIDENCE>
|
| 1538 |
{evidence}
|
| 1539 |
</FROZEN_RESEARCH_EVIDENCE>
|
| 1540 |
+
<IMMUTABLE_HYPOTHESIS_CONTRACT>
|
| 1541 |
+
{str(hypothesis_contract or '')}
|
| 1542 |
+
</IMMUTABLE_HYPOTHESIS_CONTRACT>
|
| 1543 |
+
If the immutable hypothesis contract is non-empty, preserve its H-identifiers and construct pairings exactly; do not invent or reorder hypotheses.
|
| 1544 |
<COMPLETED_FROZEN_MANUSCRIPT>
|
| 1545 |
{body}
|
| 1546 |
</COMPLETED_FROZEN_MANUSCRIPT>
|
|
|
|
| 2475 |
"""
|
| 2476 |
with self.lock:
|
| 2477 |
self.citation_map.clear()
|
| 2478 |
+
self.retrieval_query_anchors.clear()
|
| 2479 |
self.used_citations.clear()
|
| 2480 |
self.citation_audit_log.clear()
|
| 2481 |
for source in ('s2', 'oa'):
|
|
|
|
| 2555 |
all_used_terms.add(term)
|
| 2556 |
if not normalized_terms:
|
| 2557 |
continue
|
| 2558 |
+
for term in normalized_terms:
|
| 2559 |
+
if term not in self.retrieval_query_anchors:
|
| 2560 |
+
self.retrieval_query_anchors.append(term)
|
| 2561 |
|
| 2562 |
run_wave(normalized_terms, second_page=False)
|
| 2563 |
if unique_preview_count() >= min_required:
|
|
|
|
| 2614 |
return int(match.group()) if match else 10**9
|
| 2615 |
|
| 2616 |
@staticmethod
|
| 2617 |
+
def _citation_term_sequence(value):
|
| 2618 |
stopwords = {
|
| 2619 |
"about", "after", "also", "among", "and", "are", "because",
|
| 2620 |
"been", "before", "between", "both", "can", "could", "does",
|
|
|
|
| 2625 |
"with", "within", "would",
|
| 2626 |
}
|
| 2627 |
words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", str(value).lower())
|
| 2628 |
+
return [word for word in words if word not in stopwords]
|
| 2629 |
+
|
| 2630 |
+
@classmethod
|
| 2631 |
+
def _citation_terms(cls, value):
|
| 2632 |
+
return set(cls._citation_term_sequence(value))
|
| 2633 |
|
| 2634 |
def build_claim_evidence_plan(self, tasks, topic, synthesis_result, mode='amj', target_unique=60):
|
| 2635 |
"""Build a bounded citation-first plan without one oversized model call.
|
|
|
|
| 2843 |
'use','uses','used','using','user','users','approach','approaches','framework','frameworks',
|
| 2844 |
})
|
| 2845 |
|
| 2846 |
+
_CITATION_RECOVERY_QUERY_NOISE_TERMS = frozenset({
|
| 2847 |
+
'academic','academics','accept','accepted','acceptance','acceptable','access','accessed',
|
| 2848 |
+
'account','accounts','absence','absent','abandoned','accomplish','accomplished','achieve',
|
| 2849 |
+
'achieved','achieves','across','accordingly','additional','aim','aims','allow','allows',
|
| 2850 |
+
'available','based','basis','consider','considered','current','different','evidence',
|
| 2851 |
+
'existing','finding','findings','important','indicate','indicates','paper','prior','process',
|
| 2852 |
+
'processes','provide','provides','related','relationship','relationships','relevant','report',
|
| 2853 |
+
'reports','show','shows','specific','support','supports','introduction','discussion',
|
| 2854 |
+
'background','theoretical','framing','methodology','setting','results','hypothesis','hypotheses',
|
| 2855 |
+
})
|
| 2856 |
+
|
| 2857 |
@classmethod
|
| 2858 |
def _citation_recovery_overlap(cls, section_name, section_context, claim, evidence):
|
| 2859 |
anchor_terms = cls._citation_terms(f"{section_name} {section_context}")
|
|
|
|
| 2920 |
|
| 2921 |
def select_contextual_citation_recovery_items(
|
| 2922 |
self, items, *, section_name='', section_id='', section_context='', needed=1, excluded_tags=None,
|
| 2923 |
+
allowed_tags=None,
|
| 2924 |
):
|
| 2925 |
"""Select only recovery citations that are actually about the current unit.
|
| 2926 |
|
|
|
|
| 2933 |
if needed <= 0:
|
| 2934 |
return []
|
| 2935 |
excluded = {str(tag or '').strip() for tag in (excluded_tags or set()) if str(tag or '').strip()}
|
| 2936 |
+
allowed = None if allowed_tags is None else {
|
| 2937 |
+
str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
|
| 2938 |
+
}
|
| 2939 |
ranked = []
|
| 2940 |
seen = set()
|
| 2941 |
for item in items or []:
|
|
|
|
| 2944 |
tag = str(item.get('tag') or '').strip()
|
| 2945 |
if not tag or tag in excluded or tag in seen:
|
| 2946 |
continue
|
| 2947 |
+
if allowed is not None and tag not in allowed:
|
| 2948 |
+
continue
|
| 2949 |
if not self.citation_source_role_eligible(
|
| 2950 |
tag, section_id=section_id, section_name=section_name
|
| 2951 |
):
|
|
|
|
| 2967 |
for tag, meta in self.citation_map.items():
|
| 2968 |
if tag in used:
|
| 2969 |
continue
|
| 2970 |
+
if allowed is not None and tag not in allowed:
|
| 2971 |
+
continue
|
| 2972 |
if not self.citation_source_role_eligible(
|
| 2973 |
tag, metadata=meta, section_id=section_id, section_name=section_name
|
| 2974 |
):
|
|
|
|
| 2993 |
return selected
|
| 2994 |
|
| 2995 |
def _deterministic_contextual_jit_query(self, topic, section_name, section_context):
|
| 2996 |
+
"""Build one bounded topic/construct-first recovery query without a model call.
|
| 2997 |
+
|
| 2998 |
+
C5.1 deliberately preserves semantic/source order. The previous code
|
| 2999 |
+
converted the manuscript context to a set and then alphabetically sorted
|
| 3000 |
+
it, which produced queries such as ``academic accept access ...``. Topic
|
| 3001 |
+
terms now anchor the query, while context terms are ranked by occurrence
|
| 3002 |
+
frequency with first appearance as the deterministic tie-breaker.
|
| 3003 |
+
"""
|
| 3004 |
+
noise = set(self._CITATION_RECOVERY_GENERIC_TERMS) | set(self._CITATION_RECOVERY_QUERY_NOISE_TERMS)
|
| 3005 |
+
|
| 3006 |
+
def clean_sequence(value):
|
| 3007 |
+
output = []
|
| 3008 |
+
for term in self._citation_term_sequence(value):
|
| 3009 |
+
if len(term) < 3 or term in noise or term in output:
|
| 3010 |
+
continue
|
| 3011 |
+
output.append(term)
|
| 3012 |
+
return output
|
| 3013 |
+
|
| 3014 |
+
topic_terms = clean_sequence(topic)
|
| 3015 |
+
retrieval_anchor_terms = []
|
| 3016 |
+
for query in getattr(self, 'retrieval_query_anchors', []):
|
| 3017 |
+
for term in clean_sequence(query):
|
| 3018 |
+
if term not in retrieval_anchor_terms:
|
| 3019 |
+
retrieval_anchor_terms.append(term)
|
| 3020 |
+
# C5.2: Chinese/Russian/Kazakh topic text may contain no ASCII tokens.
|
| 3021 |
+
# Reuse the English librarian queries that actually seeded the frozen
|
| 3022 |
+
# literature pool; if those anchors are unavailable, fail closed rather
|
| 3023 |
+
# than falling back to generic section prose vocabulary.
|
| 3024 |
+
use_retrieval_anchor = len(topic_terms) < 2
|
| 3025 |
+
effective_topic_terms = topic_terms if not use_retrieval_anchor else retrieval_anchor_terms
|
| 3026 |
+
section_terms = clean_sequence(section_name)
|
| 3027 |
+
raw_context = [
|
| 3028 |
+
term for term in self._citation_term_sequence(section_context)
|
| 3029 |
+
if len(term) >= 3 and term not in noise
|
| 3030 |
]
|
| 3031 |
+
counts = Counter(raw_context)
|
| 3032 |
+
first_index = {}
|
| 3033 |
+
for index, term in enumerate(raw_context):
|
| 3034 |
+
first_index.setdefault(term, index)
|
| 3035 |
+
context_terms = sorted(
|
| 3036 |
+
counts,
|
| 3037 |
+
key=lambda term: (-counts[term], first_index[term]),
|
| 3038 |
+
)
|
| 3039 |
+
|
| 3040 |
selected = []
|
| 3041 |
+
# A recovery query should remain anchored in the manuscript topic rather
|
| 3042 |
+
# than drifting toward alphabetically early prose vocabulary.
|
| 3043 |
+
topic_target = min(5 if use_retrieval_anchor else 3, len(effective_topic_terms))
|
| 3044 |
+
for term in effective_topic_terms[:topic_target]:
|
| 3045 |
+
if term not in selected:
|
| 3046 |
+
selected.append(term)
|
| 3047 |
+
tail_terms = (
|
| 3048 |
+
effective_topic_terms[topic_target:] + section_terms + context_terms
|
| 3049 |
+
if use_retrieval_anchor
|
| 3050 |
+
else section_terms + context_terms + effective_topic_terms[topic_target:]
|
| 3051 |
+
)
|
| 3052 |
+
for term in tail_terms:
|
| 3053 |
+
if term not in selected:
|
| 3054 |
+
selected.append(term)
|
| 3055 |
if len(selected) >= 5:
|
| 3056 |
break
|
| 3057 |
+
|
| 3058 |
+
# When two or more topic/construct-specific terms are available, at
|
| 3059 |
+
# least two must survive into the bounded query. Otherwise skip JIT
|
| 3060 |
+
# instead of spending retrieval budget on a semantically weak query.
|
| 3061 |
+
if len(effective_topic_terms) < 2:
|
| 3062 |
+
return ''
|
| 3063 |
+
if len(set(selected) & set(effective_topic_terms)) < 2:
|
| 3064 |
+
return ''
|
| 3065 |
+
if len(selected) < 3:
|
| 3066 |
+
return ''
|
| 3067 |
+
if any(term in noise for term in selected):
|
| 3068 |
+
return ''
|
| 3069 |
+
return ' '.join(selected[:5])
|
| 3070 |
|
| 3071 |
def fetch_contextual_jit_recovery_items(
|
| 3072 |
self, *, topic, section_name, section_context, section_id='', needed=1, excluded_tags=None,
|
|
|
|
| 3350 |
supports_section_id = True
|
| 3351 |
if supports_section_id:
|
| 3352 |
audit_kwargs['section_id'] = section_id
|
| 3353 |
+
try:
|
| 3354 |
+
supports_allowed_tags = any(
|
| 3355 |
+
item.name == 'allowed_tags' or item.kind == inspect.Parameter.VAR_KEYWORD
|
| 3356 |
+
for item in parameters
|
| 3357 |
+
)
|
| 3358 |
+
except Exception:
|
| 3359 |
+
supports_allowed_tags = True
|
| 3360 |
+
if supports_allowed_tags:
|
| 3361 |
+
audit_kwargs['allowed_tags'] = expected_tags
|
| 3362 |
audited = audit_method(proposed, **audit_kwargs)
|
| 3363 |
except PipelineCancelledError:
|
| 3364 |
raise
|
|
|
|
| 3405 |
return ''
|
| 3406 |
|
| 3407 |
def _candidate_citation_tags(
|
| 3408 |
+
self, sentence, required_tags, limit, *, section_id='', section_name='', allowed_tags=None
|
| 3409 |
):
|
| 3410 |
"""Return source-role-eligible semantic-audit candidates for one claim.
|
| 3411 |
|
|
|
|
| 3415 |
them. Lexical overlap only ranks eligible candidates; it never authorizes
|
| 3416 |
attachment by itself.
|
| 3417 |
"""
|
| 3418 |
+
allowed = None if allowed_tags is None else {
|
| 3419 |
+
str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
|
| 3420 |
+
}
|
| 3421 |
valid_required = [
|
| 3422 |
tag for tag in required_tags
|
| 3423 |
+
if tag in self.citation_map
|
| 3424 |
+
and (allowed is None or tag in allowed)
|
| 3425 |
+
and self.citation_source_role_eligible(
|
| 3426 |
tag, section_id=section_id, section_name=section_name
|
| 3427 |
)
|
| 3428 |
]
|
|
|
|
| 3431 |
for tag, metadata in self.citation_map.items():
|
| 3432 |
if tag in valid_required:
|
| 3433 |
continue
|
| 3434 |
+
if allowed is not None and tag not in allowed:
|
| 3435 |
+
continue
|
| 3436 |
if not self.citation_source_role_eligible(
|
| 3437 |
tag, metadata=metadata, section_id=section_id, section_name=section_name
|
| 3438 |
):
|
|
|
|
| 4288 |
core = f"{core} {tag}{punctuation}".strip()
|
| 4289 |
return f"{leading}{core}{trailing}"
|
| 4290 |
|
| 4291 |
+
def _adjudicate_single_source_split(self, *, sentence, selected_tag, evidence, split_reason, section_name=''):
|
| 4292 |
+
"""Decide whether one selected source supports the complete compound claim.
|
| 4293 |
+
|
| 4294 |
+
This is a bounded decision-only semantic check. It has no prose rewrite,
|
| 4295 |
+
citation invention, or retrieval authority. Any malformed/failed decision
|
| 4296 |
+
fails closed as partial support so the existing same-Section retry owns the
|
| 4297 |
+
correction.
|
| 4298 |
+
"""
|
| 4299 |
+
selected_evidence = ''
|
| 4300 |
+
selected_reference = ''
|
| 4301 |
+
for row in evidence or []:
|
| 4302 |
+
if str(row.get('tag') or '').strip() == str(selected_tag or '').strip():
|
| 4303 |
+
selected_evidence = str(row.get('evidence') or '').strip()
|
| 4304 |
+
selected_reference = str(row.get('reference') or '').strip()
|
| 4305 |
+
break
|
| 4306 |
+
payload = {
|
| 4307 |
+
'section': str(section_name or ''),
|
| 4308 |
+
'sentence': str(sentence or ''),
|
| 4309 |
+
'selected_tag': str(selected_tag or ''),
|
| 4310 |
+
'selected_reference': selected_reference,
|
| 4311 |
+
'selected_evidence': selected_evidence,
|
| 4312 |
+
'original_split_reason': str(split_reason or '')[:700],
|
| 4313 |
+
}
|
| 4314 |
+
system_prompt = (
|
| 4315 |
+
"You are a strict citation-support adjudicator. Decide only whether the supplied single source directly supports "
|
| 4316 |
+
"the complete original sentence, including every material clause. Do not rewrite prose, add citations, infer from title alone, "
|
| 4317 |
+
"or use outside knowledge. Return JSON only: "
|
| 4318 |
+
'{"decision":"complete_support|partial_or_unsupported","reason":"brief evidence-grounded reason"}.'
|
| 4319 |
+
)
|
| 4320 |
+
try:
|
| 4321 |
+
response = self.call_deepseek(
|
| 4322 |
+
system_prompt,
|
| 4323 |
+
json.dumps(payload, ensure_ascii=False),
|
| 4324 |
+
max_tokens=700,
|
| 4325 |
+
temp=0.0,
|
| 4326 |
+
model_role='reasoning',
|
| 4327 |
+
thinking=False,
|
| 4328 |
+
allow_fallback=False,
|
| 4329 |
+
)
|
| 4330 |
+
parsed = self._extract_json_from_text(response)
|
| 4331 |
+
if isinstance(parsed, dict):
|
| 4332 |
+
decision = str(parsed.get('decision') or '').strip().lower()
|
| 4333 |
+
reason = str(parsed.get('reason') or '').strip()
|
| 4334 |
+
if decision == 'complete_support':
|
| 4335 |
+
return True, reason
|
| 4336 |
+
if decision == 'partial_or_unsupported':
|
| 4337 |
+
return False, reason
|
| 4338 |
+
except PipelineCancelledError:
|
| 4339 |
+
raise
|
| 4340 |
+
except Exception as exc:
|
| 4341 |
+
logging.warning(
|
| 4342 |
+
'Citation split adjudication failed closed: section=%s error=%s',
|
| 4343 |
+
section_name, type(exc).__name__,
|
| 4344 |
+
)
|
| 4345 |
+
return False, 'Single-source split adjudication did not confirm complete support.'
|
| 4346 |
+
|
| 4347 |
def semantic_audit_claim_specific_citations(
|
| 4348 |
self,
|
| 4349 |
text,
|
| 4350 |
section_name="",
|
| 4351 |
section_id="",
|
| 4352 |
+
allowed_tags=None,
|
| 4353 |
):
|
| 4354 |
"""Audit citation support while granting the audit TAG-ONLY mutation authority.
|
| 4355 |
|
|
|
|
| 4393 |
sentence,
|
| 4394 |
required_tags,
|
| 4395 |
candidate_limit,
|
| 4396 |
+
section_id=section_id, section_name=section_name, allowed_tags=allowed_tags,
|
| 4397 |
)
|
| 4398 |
evidence = []
|
| 4399 |
for tag in candidates:
|
|
|
|
| 4566 |
)
|
| 4567 |
continue
|
| 4568 |
if status == "split":
|
| 4569 |
+
# C5.1: ``split + one tag`` is an inconsistent audit decision,
|
| 4570 |
+
# not proof of complete support. Re-adjudicate the same
|
| 4571 |
+
# sentence/source pair once, without rewrite/retrieval authority.
|
| 4572 |
+
# This preserves C2's legitimate full-support recovery while
|
| 4573 |
+
# preventing a partially unsupported clause from inheriting an
|
| 4574 |
+
# overbroad sentence-final citation.
|
| 4575 |
if len(revised_tags) == 1:
|
| 4576 |
selected = next(iter(revised_tags))
|
| 4577 |
+
complete_support, adjudication_reason = self._adjudicate_single_source_split(
|
| 4578 |
+
sentence=record["original"], selected_tag=selected,
|
| 4579 |
+
evidence=record.get("evidence") or [], split_reason=reason,
|
| 4580 |
+
section_name=section_name,
|
|
|
|
|
|
|
|
|
|
| 4581 |
)
|
| 4582 |
+
if complete_support:
|
| 4583 |
+
replacements[record["id"]] = self._citation_tag_only_rewrite(
|
| 4584 |
+
record["original"], selected
|
| 4585 |
+
)
|
| 4586 |
+
self._append_citation_audit(
|
| 4587 |
+
section_name, "normalize_inconsistent_split_to_single_source",
|
| 4588 |
+
record["original"], replacements[record["id"]],
|
| 4589 |
+
adjudication_reason or reason or "Decision-only adjudication confirmed complete support.",
|
| 4590 |
+
)
|
| 4591 |
+
continue
|
| 4592 |
+
reason = adjudication_reason or reason
|
| 4593 |
violation = {
|
| 4594 |
"type":"citation_claim_split_required", "id":record["id"],
|
| 4595 |
"sentence":record["original"][:500], "reason":reason,
|
|
|
|
| 4792 |
)
|
| 4793 |
return revised
|
| 4794 |
|
| 4795 |
+
def audit_and_resolve_citations(
|
| 4796 |
+
self, text, section_name='', section_id='', quarantine=False, allowed_tags=None
|
| 4797 |
+
):
|
| 4798 |
self._last_citation_semantic_violations = []
|
| 4799 |
value = str(text or '')
|
| 4800 |
if quarantine:
|
|
|
|
| 4804 |
for tag in unknown:
|
| 4805 |
value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
|
| 4806 |
self._append_citation_audit(section_name, 'remove_unknown_tags', text, value, ','.join(unknown))
|
| 4807 |
+
allowed = None if allowed_tags is None else {
|
| 4808 |
+
str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
|
| 4809 |
+
}
|
| 4810 |
+
outside_rows = []
|
| 4811 |
+
if allowed is not None:
|
| 4812 |
+
present = {f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)}
|
| 4813 |
+
outside = sorted(present - allowed, key=self._citation_tag_number)
|
| 4814 |
+
for tag in outside:
|
| 4815 |
+
outside_rows.append({
|
| 4816 |
+
'type': 'citation_outside_frozen_section_whitelist',
|
| 4817 |
+
'tag': tag,
|
| 4818 |
+
'sentence': tag,
|
| 4819 |
+
'reason': 'Citation tag is outside the frozen evidence whitelist for this manuscript unit.',
|
| 4820 |
+
'repair_instruction': 'Use only citation tags frozen for this manuscript unit, or narrow the claim so no unsupported citation is needed.',
|
| 4821 |
+
})
|
| 4822 |
+
value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
|
| 4823 |
+
self._append_citation_audit(
|
| 4824 |
+
section_name, 'reject_outside_frozen_section_whitelist', text, value, tag
|
| 4825 |
+
)
|
| 4826 |
has_citations = bool(re.search(r'CITE-\d+', value, re.I))
|
| 4827 |
violations = self.find_multi_citation_violations(value)
|
| 4828 |
if has_citations and self.citation_semantic_audit.get('enabled', True):
|
| 4829 |
try:
|
| 4830 |
value = self.semantic_audit_claim_specific_citations(
|
| 4831 |
+
value, section_name=section_name, section_id=section_id, allowed_tags=allowed
|
| 4832 |
)
|
| 4833 |
except PipelineCancelledError:
|
| 4834 |
raise
|
|
|
|
| 4853 |
value = self._local_citation_recovery(value, section_name, retain_lexical=False)
|
| 4854 |
elif violations:
|
| 4855 |
value = self._local_citation_recovery(value, section_name)
|
| 4856 |
+
if outside_rows:
|
| 4857 |
+
self._last_citation_semantic_violations = list(
|
| 4858 |
+
self._last_citation_semantic_violations or []
|
| 4859 |
+
) + outside_rows
|
| 4860 |
if self.find_multi_citation_violations(value):
|
| 4861 |
value = self._local_citation_recovery(value, section_name)
|
| 4862 |
if self.find_multi_citation_violations(value):
|