Spaces:
Running
Running
S0-R1-C1 Citation Source-Role + Retry Evidence Ownership
#74
by GuXiaopeng - opened
- core/generation_runner.py +67 -18
- core/pipeline_agent.py +49 -7
core/generation_runner.py
CHANGED
|
@@ -958,6 +958,24 @@ class GenerationRunner:
|
|
| 958 |
rows.append(' | '.join(parts))
|
| 959 |
return '\n'.join(f'- {row}' for row in rows)
|
| 960 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 961 |
@staticmethod
|
| 962 |
def _snapshot_section_citation_state(agent, citation_plan: dict, unit_name: str) -> dict:
|
| 963 |
"""Snapshot only Section-owned citation state so a rejected attempt cannot leak forward."""
|
|
@@ -2327,8 +2345,9 @@ class GenerationRunner:
|
|
| 2327 |
task_by_name.get(display_name, {}), research_logic_contract, synthesis_result
|
| 2328 |
)
|
| 2329 |
recovery_items = agent.select_contextual_citation_recovery_items(
|
| 2330 |
-
[], section_name=display_name,
|
| 2331 |
-
|
|
|
|
| 2332 |
)
|
| 2333 |
for item in recovery_items:
|
| 2334 |
tag = str(item.get("tag") or "")
|
|
@@ -2355,8 +2374,8 @@ class GenerationRunner:
|
|
| 2355 |
for name in section_units
|
| 2356 |
)
|
| 2357 |
jit_items = agent.fetch_contextual_jit_recovery_items(
|
| 2358 |
-
topic=topic, section_name=section_id,
|
| 2359 |
-
needed=deficit, excluded_tags=planned_tags,
|
| 2360 |
)
|
| 2361 |
planned_items = citation_plan.setdefault(primary_unit, [])
|
| 2362 |
for item in jit_items:
|
|
@@ -2805,10 +2824,16 @@ class GenerationRunner:
|
|
| 2805 |
word in sec_title.lower()
|
| 2806 |
for word in ["abstract", "keyword", "conclusion", "references", "appendix"]
|
| 2807 |
)
|
| 2808 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2809 |
assigned_tag_list = [str(item.get("tag") or "") for item in planned_items if item.get("tag")]
|
| 2810 |
claim_plan_text = agent.format_claim_evidence_plan(planned_items)
|
| 2811 |
-
section_id = str(sec.get("section_id") or "")
|
| 2812 |
section_role = str(
|
| 2813 |
APP_CONFIG.get("section_reasoning_profiles", {}).get(mode, {}).get(section_id) or ""
|
| 2814 |
)
|
|
@@ -2993,6 +3018,7 @@ Output only final academic prose. Do not report the word count."""
|
|
| 2993 |
agent, citation_plan, display_name
|
| 2994 |
)
|
| 2995 |
attempt_feedback = ""
|
|
|
|
| 2996 |
en_resolved = ""
|
| 2997 |
survived = set()
|
| 2998 |
|
|
@@ -3004,15 +3030,32 @@ Output only final academic prose. Do not report the word count."""
|
|
| 3004 |
self._restore_section_citation_state(
|
| 3005 |
agent, citation_plan, display_name, frozen_citation_state
|
| 3006 |
)
|
| 3007 |
-
planned_items =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3008 |
citation_plan[display_name] = planned_items
|
|
|
|
|
|
|
|
|
|
| 3009 |
attempt_diagnostics = []
|
| 3010 |
candidate_user_prompt = user_prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3011 |
if attempt_feedback:
|
|
|
|
| 3012 |
candidate_user_prompt += f"""
|
| 3013 |
|
| 3014 |
<SECTION_RETRY_VALIDATION_FEEDBACK>
|
| 3015 |
-
The previous attempt for this same manuscript unit was rejected by validation. Rewrite ONLY this same unit once. Keep the identical frozen evidence
|
| 3016 |
{attempt_feedback}
|
| 3017 |
</SECTION_RETRY_VALIDATION_FEEDBACK>"""
|
| 3018 |
if agent._count_tokens(sys_prompt) + agent._count_tokens(candidate_user_prompt) > 60000:
|
|
@@ -3082,9 +3125,9 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3082 |
allow_deterministic_citation_reinforcement = not (
|
| 3083 |
mode == "asq" and section_id in {"methods", "discussion"}
|
| 3084 |
)
|
| 3085 |
-
if not is_quarantined and
|
| 3086 |
en_raw = agent.reinforce_claim_specific_citation_coverage(
|
| 3087 |
-
en_raw,
|
| 3088 |
)
|
| 3089 |
self._progress(pct, f"citation_audit:{current_index}:{total_steps}")
|
| 3090 |
used_before = set(agent.used_citations)
|
|
@@ -3099,6 +3142,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3099 |
)
|
| 3100 |
if citation_semantic_rows:
|
| 3101 |
if section_attempt < max_section_attempts:
|
|
|
|
| 3102 |
attempt_feedback = self._section_retry_feedback(citation_semantic_rows)
|
| 3103 |
logging.warning(
|
| 3104 |
"Citation semantic Section retry | unit=%s attempt=%s count=%s types=%s",
|
|
@@ -3127,10 +3171,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3127 |
|
| 3128 |
used_after = set(agent.used_citations)
|
| 3129 |
survived = used_after - used_before
|
| 3130 |
-
if
|
| 3131 |
required_survival = min(
|
| 3132 |
-
len(
|
| 3133 |
-
max(1, int(math.ceil(len(
|
| 3134 |
)
|
| 3135 |
if len(survived) < required_survival:
|
| 3136 |
missing_items = [
|
|
@@ -3139,17 +3183,21 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3139 |
]
|
| 3140 |
needed = max(0, required_survival - len(survived))
|
| 3141 |
recovery_items = agent.select_contextual_citation_recovery_items(
|
| 3142 |
-
missing_items, section_name=display_name,
|
| 3143 |
-
|
|
|
|
| 3144 |
)
|
| 3145 |
# Evidence retrieval is permitted only on the first writer
|
| 3146 |
# attempt. Attempt 2 is bound to the original frozen packet.
|
| 3147 |
if section_attempt == 1 and len(recovery_items) < needed:
|
| 3148 |
recovery_items.extend(
|
| 3149 |
agent.fetch_contextual_jit_recovery_items(
|
| 3150 |
-
topic=topic, section_name=display_name,
|
| 3151 |
-
needed=needed - len(recovery_items),
|
| 3152 |
-
excluded_tags=
|
|
|
|
|
|
|
|
|
|
| 3153 |
)
|
| 3154 |
)
|
| 3155 |
planned_tags_for_unit = {str(item.get("tag") or "") for item in planned_items}
|
|
@@ -3260,6 +3308,7 @@ The previous attempt for this same manuscript unit was rejected by validation. R
|
|
| 3260 |
)
|
| 3261 |
|
| 3262 |
quality_diagnostics.extend(attempt_diagnostics)
|
|
|
|
| 3263 |
state.pop("section_retry_exhausted", None)
|
| 3264 |
break
|
| 3265 |
|
|
|
|
| 958 |
rows.append(' | '.join(parts))
|
| 959 |
return '\n'.join(f'- {row}' for row in rows)
|
| 960 |
|
| 961 |
+
@staticmethod
|
| 962 |
+
def _citation_violation_tags(rows) -> set:
|
| 963 |
+
"""Extract only citation tags owned by the rejected claim sentence.
|
| 964 |
+
|
| 965 |
+
S0-R1-C1 uses this set as a bounded retry exclusion. Reasons may mention
|
| 966 |
+
comparison sources, so only explicit tag fields and rejected sentences are
|
| 967 |
+
inspected; this avoids excluding unrelated frozen evidence.
|
| 968 |
+
"""
|
| 969 |
+
tags = set()
|
| 970 |
+
for row in rows or []:
|
| 971 |
+
if not isinstance(row, dict):
|
| 972 |
+
continue
|
| 973 |
+
owned = ' '.join(str(row.get(key) or '') for key in ('tag', 'citation', 'sentence'))
|
| 974 |
+
for raw in re.findall(r'\[?CITE-\d+\]?', owned, flags=re.I):
|
| 975 |
+
token = raw.upper().strip('[]')
|
| 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."""
|
|
|
|
| 2345 |
task_by_name.get(display_name, {}), research_logic_contract, synthesis_result
|
| 2346 |
)
|
| 2347 |
recovery_items = agent.select_contextual_citation_recovery_items(
|
| 2348 |
+
[], section_name=display_name,
|
| 2349 |
+
section_id=str(task_by_name.get(display_name, {}).get("section_id") or ""),
|
| 2350 |
+
section_context=section_context, needed=shortfall, excluded_tags=planned_tags,
|
| 2351 |
)
|
| 2352 |
for item in recovery_items:
|
| 2353 |
tag = str(item.get("tag") or "")
|
|
|
|
| 2374 |
for name in section_units
|
| 2375 |
)
|
| 2376 |
jit_items = agent.fetch_contextual_jit_recovery_items(
|
| 2377 |
+
topic=topic, section_name=section_id, section_id=section_id,
|
| 2378 |
+
section_context=section_context, needed=deficit, excluded_tags=planned_tags,
|
| 2379 |
)
|
| 2380 |
planned_items = citation_plan.setdefault(primary_unit, [])
|
| 2381 |
for item in jit_items:
|
|
|
|
| 2824 |
word in sec_title.lower()
|
| 2825 |
for word in ["abstract", "keyword", "conclusion", "references", "appendix"]
|
| 2826 |
)
|
| 2827 |
+
section_id = str(sec.get("section_id") or "")
|
| 2828 |
+
planned_items = [
|
| 2829 |
+
item for item in list(citation_plan.get(display_name, []))
|
| 2830 |
+
if agent.citation_source_role_eligible(
|
| 2831 |
+
str(item.get("tag") or ""), section_id=section_id, section_name=display_name
|
| 2832 |
+
)
|
| 2833 |
+
]
|
| 2834 |
+
citation_plan[display_name] = copy.deepcopy(planned_items)
|
| 2835 |
assigned_tag_list = [str(item.get("tag") or "") for item in planned_items if item.get("tag")]
|
| 2836 |
claim_plan_text = agent.format_claim_evidence_plan(planned_items)
|
|
|
|
| 2837 |
section_role = str(
|
| 2838 |
APP_CONFIG.get("section_reasoning_profiles", {}).get(mode, {}).get(section_id) or ""
|
| 2839 |
)
|
|
|
|
| 3018 |
agent, citation_plan, display_name
|
| 3019 |
)
|
| 3020 |
attempt_feedback = ""
|
| 3021 |
+
retry_excluded_tags = set()
|
| 3022 |
en_resolved = ""
|
| 3023 |
survived = set()
|
| 3024 |
|
|
|
|
| 3030 |
self._restore_section_citation_state(
|
| 3031 |
agent, citation_plan, display_name, frozen_citation_state
|
| 3032 |
)
|
| 3033 |
+
planned_items = [
|
| 3034 |
+
item for item in copy.deepcopy(list(citation_plan.get(display_name, []) or []))
|
| 3035 |
+
if str(item.get("tag") or "") not in retry_excluded_tags
|
| 3036 |
+
and agent.citation_source_role_eligible(
|
| 3037 |
+
str(item.get("tag") or ""), section_id=section_id, section_name=display_name
|
| 3038 |
+
)
|
| 3039 |
+
]
|
| 3040 |
citation_plan[display_name] = planned_items
|
| 3041 |
+
attempt_assigned_tag_list = [
|
| 3042 |
+
str(item.get("tag") or "") for item in planned_items if item.get("tag")
|
| 3043 |
+
]
|
| 3044 |
attempt_diagnostics = []
|
| 3045 |
candidate_user_prompt = user_prompt
|
| 3046 |
+
if retry_excluded_tags:
|
| 3047 |
+
retry_claim_plan = agent.format_claim_evidence_plan(planned_items)
|
| 3048 |
+
candidate_user_prompt = re.sub(
|
| 3049 |
+
r"<CLAIM_EVIDENCE_PLAN>.*?</CLAIM_EVIDENCE_PLAN>",
|
| 3050 |
+
f"<CLAIM_EVIDENCE_PLAN>\n{retry_claim_plan}\n</CLAIM_EVIDENCE_PLAN>",
|
| 3051 |
+
candidate_user_prompt, flags=re.S,
|
| 3052 |
+
)
|
| 3053 |
if attempt_feedback:
|
| 3054 |
+
excluded_text = ", ".join(sorted(retry_excluded_tags, key=agent._citation_tag_number)) or "None"
|
| 3055 |
candidate_user_prompt += f"""
|
| 3056 |
|
| 3057 |
<SECTION_RETRY_VALIDATION_FEEDBACK>
|
| 3058 |
+
The previous attempt for this same manuscript unit was rejected by validation. Rewrite ONLY this same unit once. Keep the identical frozen evidence universe, research logic, and section role. Do not fetch, invent, or broaden evidence. Citation tags rejected for this claim context are ineligible on the retry and must not be reused or deterministically reinserted: {excluded_text}. Use another already-frozen eligible source only when it directly supports the rewritten claim, or narrow/qualify the claim so no unsupported citation is needed. Resolve every material issue below without changing the task or manuscript scope:
|
| 3059 |
{attempt_feedback}
|
| 3060 |
</SECTION_RETRY_VALIDATION_FEEDBACK>"""
|
| 3061 |
if agent._count_tokens(sys_prompt) + agent._count_tokens(candidate_user_prompt) > 60000:
|
|
|
|
| 3125 |
allow_deterministic_citation_reinforcement = not (
|
| 3126 |
mode == "asq" and section_id in {"methods", "discussion"}
|
| 3127 |
)
|
| 3128 |
+
if not is_quarantined and attempt_assigned_tag_list and allow_deterministic_citation_reinforcement:
|
| 3129 |
en_raw = agent.reinforce_claim_specific_citation_coverage(
|
| 3130 |
+
en_raw, attempt_assigned_tag_list, section_name=display_name, section_id=section_id
|
| 3131 |
)
|
| 3132 |
self._progress(pct, f"citation_audit:{current_index}:{total_steps}")
|
| 3133 |
used_before = set(agent.used_citations)
|
|
|
|
| 3142 |
)
|
| 3143 |
if citation_semantic_rows:
|
| 3144 |
if section_attempt < max_section_attempts:
|
| 3145 |
+
retry_excluded_tags.update(self._citation_violation_tags(citation_semantic_rows))
|
| 3146 |
attempt_feedback = self._section_retry_feedback(citation_semantic_rows)
|
| 3147 |
logging.warning(
|
| 3148 |
"Citation semantic Section retry | unit=%s attempt=%s count=%s types=%s",
|
|
|
|
| 3171 |
|
| 3172 |
used_after = set(agent.used_citations)
|
| 3173 |
survived = used_after - used_before
|
| 3174 |
+
if attempt_assigned_tag_list and not is_quarantined:
|
| 3175 |
required_survival = min(
|
| 3176 |
+
len(attempt_assigned_tag_list),
|
| 3177 |
+
max(1, int(math.ceil(len(attempt_assigned_tag_list) * CITATION_SEMANTIC_SURVIVAL_RATIO))),
|
| 3178 |
)
|
| 3179 |
if len(survived) < required_survival:
|
| 3180 |
missing_items = [
|
|
|
|
| 3183 |
]
|
| 3184 |
needed = max(0, required_survival - len(survived))
|
| 3185 |
recovery_items = agent.select_contextual_citation_recovery_items(
|
| 3186 |
+
missing_items, section_name=display_name, section_id=section_id,
|
| 3187 |
+
section_context=en_resolved, needed=needed,
|
| 3188 |
+
excluded_tags=used_after | retry_excluded_tags,
|
| 3189 |
)
|
| 3190 |
# Evidence retrieval is permitted only on the first writer
|
| 3191 |
# attempt. Attempt 2 is bound to the original frozen packet.
|
| 3192 |
if section_attempt == 1 and len(recovery_items) < needed:
|
| 3193 |
recovery_items.extend(
|
| 3194 |
agent.fetch_contextual_jit_recovery_items(
|
| 3195 |
+
topic=topic, section_name=display_name, section_id=section_id,
|
| 3196 |
+
section_context=en_resolved, needed=needed - len(recovery_items),
|
| 3197 |
+
excluded_tags=(
|
| 3198 |
+
used_after | retry_excluded_tags
|
| 3199 |
+
| {str(item.get("tag") or "") for item in recovery_items}
|
| 3200 |
+
),
|
| 3201 |
)
|
| 3202 |
)
|
| 3203 |
planned_tags_for_unit = {str(item.get("tag") or "") for item in planned_items}
|
|
|
|
| 3308 |
)
|
| 3309 |
|
| 3310 |
quality_diagnostics.extend(attempt_diagnostics)
|
| 3311 |
+
assigned_tag_list = list(attempt_assigned_tag_list)
|
| 3312 |
state.pop("section_retry_exhausted", None)
|
| 3313 |
break
|
| 3314 |
|
core/pipeline_agent.py
CHANGED
|
@@ -2744,8 +2744,9 @@ INPUT:
|
|
| 2744 |
if len(self._citation_terms(claim) & self._citation_terms(evidence)) < 2:
|
| 2745 |
continue
|
| 2746 |
section_meta = next((item for item in sections if item['name'] == section), {})
|
| 2747 |
-
|
| 2748 |
-
|
|
|
|
| 2749 |
logging.info('Citation planner rejected METHOD_AUTHORITY outside methods: section=%s tag=%s', section, tag)
|
| 2750 |
continue
|
| 2751 |
section_context = ' '.join([
|
|
@@ -2816,6 +2817,28 @@ INPUT:
|
|
| 2816 |
distinctive = overlap_terms - cls._CITATION_RECOVERY_GENERIC_TERMS
|
| 2817 |
return len(overlap_terms), len(distinctive)
|
| 2818 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2819 |
@classmethod
|
| 2820 |
def _citation_recovery_item_relevant(cls, item, section_name='', section_context=''):
|
| 2821 |
if not str(section_context or '').strip():
|
|
@@ -2851,7 +2874,7 @@ INPUT:
|
|
| 2851 |
return ranked[0][3]
|
| 2852 |
|
| 2853 |
def select_contextual_citation_recovery_items(
|
| 2854 |
-
self, items, *, section_name='', section_context='', needed=1, excluded_tags=None,
|
| 2855 |
):
|
| 2856 |
"""Select only recovery citations that are actually about the current unit.
|
| 2857 |
|
|
@@ -2872,6 +2895,10 @@ INPUT:
|
|
| 2872 |
tag = str(item.get('tag') or '').strip()
|
| 2873 |
if not tag or tag in excluded or tag in seen:
|
| 2874 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2875 |
if not self._citation_recovery_item_relevant(item, section_name, section_context):
|
| 2876 |
continue
|
| 2877 |
overlap, distinctive = self._citation_recovery_overlap(
|
|
@@ -2889,6 +2916,10 @@ INPUT:
|
|
| 2889 |
for tag, meta in self.citation_map.items():
|
| 2890 |
if tag in used:
|
| 2891 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2892 |
evidence = re.sub(r'\s+', ' ', str(meta.get('evidence') or '')).strip()
|
| 2893 |
claim = self._best_contextual_evidence_claim(
|
| 2894 |
evidence, section_name=section_name, section_context=section_context
|
|
@@ -2935,7 +2966,7 @@ INPUT:
|
|
| 2935 |
return ' '.join(selected[:5]) if len(selected) >= 3 else ''
|
| 2936 |
|
| 2937 |
def fetch_contextual_jit_recovery_items(
|
| 2938 |
-
self, *, topic, section_name, section_context, needed=1, excluded_tags=None,
|
| 2939 |
):
|
| 2940 |
"""Bounded last-resort JIT top-up for a genuine section-specific citation gap.
|
| 2941 |
|
|
@@ -2995,7 +3026,7 @@ INPUT:
|
|
| 2995 |
tag = f"[CITE-{len(self.citation_map)+1:02d}]"
|
| 2996 |
if tag in excluded:
|
| 2997 |
continue
|
| 2998 |
-
|
| 2999 |
'in_text': res.get('in_text', ''),
|
| 3000 |
'ref': res.get('ref', ''),
|
| 3001 |
'id': keys[0] if keys else ('title_year', res.get('title'), res.get('year')),
|
|
@@ -3005,7 +3036,13 @@ INPUT:
|
|
| 3005 |
'year': res.get('year', ''),
|
| 3006 |
'evidence': evidence,
|
| 3007 |
'sources': [res.get('source', 'jit')],
|
|
|
|
| 3008 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3009 |
selected.append({'tag': tag, 'claim': claim, 'evidence': evidence[:900]})
|
| 3010 |
if len(selected) >= needed:
|
| 3011 |
break
|
|
@@ -4445,7 +4482,7 @@ Return exactly:
|
|
| 4445 |
recovered = re.sub(r'[ \t]+\n', '\n', recovered)
|
| 4446 |
return re.sub(r'\n{3,}', '\n\n', recovered).strip()
|
| 4447 |
|
| 4448 |
-
def reinforce_claim_specific_citation_coverage(self, text, assigned_tags, section_name=''):
|
| 4449 |
"""Add assigned tags only to citation-free claims with lexical evidence overlap.
|
| 4450 |
|
| 4451 |
This pass is deterministic and makes no additional model call. It never
|
|
@@ -4455,7 +4492,12 @@ Return exactly:
|
|
| 4455 |
final authority and may still reject an unsupported placement.
|
| 4456 |
"""
|
| 4457 |
value = str(text or '').strip()
|
| 4458 |
-
valid_tags = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4459 |
if not value or not valid_tags:
|
| 4460 |
return value
|
| 4461 |
existing = {f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, flags=re.I)}
|
|
|
|
| 2744 |
if len(self._citation_terms(claim) & self._citation_terms(evidence)) < 2:
|
| 2745 |
continue
|
| 2746 |
section_meta = next((item for item in sections if item['name'] == section), {})
|
| 2747 |
+
if not self.citation_source_role_eligible(
|
| 2748 |
+
tag, section_id=str(section_meta.get('section_id') or ''), section_name=section
|
| 2749 |
+
):
|
| 2750 |
logging.info('Citation planner rejected METHOD_AUTHORITY outside methods: section=%s tag=%s', section, tag)
|
| 2751 |
continue
|
| 2752 |
section_context = ' '.join([
|
|
|
|
| 2817 |
distinctive = overlap_terms - cls._CITATION_RECOVERY_GENERIC_TERMS
|
| 2818 |
return len(overlap_terms), len(distinctive)
|
| 2819 |
|
| 2820 |
+
@staticmethod
|
| 2821 |
+
def _citation_method_section(section_id='', section_name=''):
|
| 2822 |
+
sid = str(section_id or '').strip().lower()
|
| 2823 |
+
if sid in {'methods', 'methodology'}:
|
| 2824 |
+
return True
|
| 2825 |
+
name = re.sub(r'[^a-z]+', ' ', str(section_name or '').lower()).strip()
|
| 2826 |
+
return bool(re.search(r'\b(?:methods?|methodology)\b', name))
|
| 2827 |
+
|
| 2828 |
+
def citation_source_role_eligible(self, tag='', metadata=None, *, section_id='', section_name=''):
|
| 2829 |
+
"""Return whether a retrieved source role is eligible for one manuscript section.
|
| 2830 |
+
|
| 2831 |
+
S0-R1-C1 keeps role ownership deterministic across every citation path.
|
| 2832 |
+
In particular, METHOD_AUTHORITY evidence may support methodological rationale
|
| 2833 |
+
only in Methods/Methodology; reserve/recovery/reinforcement paths may not
|
| 2834 |
+
reintroduce it into substantive sections after the planner rejected it.
|
| 2835 |
+
"""
|
| 2836 |
+
meta = dict(metadata or self.citation_map.get(str(tag or '').strip()) or {})
|
| 2837 |
+
lanes = {str(value or '').strip().lower() for value in (meta.get('evidence_lanes') or [])}
|
| 2838 |
+
if 'method_authority' in lanes and not self._citation_method_section(section_id, section_name):
|
| 2839 |
+
return False
|
| 2840 |
+
return True
|
| 2841 |
+
|
| 2842 |
@classmethod
|
| 2843 |
def _citation_recovery_item_relevant(cls, item, section_name='', section_context=''):
|
| 2844 |
if not str(section_context or '').strip():
|
|
|
|
| 2874 |
return ranked[0][3]
|
| 2875 |
|
| 2876 |
def select_contextual_citation_recovery_items(
|
| 2877 |
+
self, items, *, section_name='', section_id='', section_context='', needed=1, excluded_tags=None,
|
| 2878 |
):
|
| 2879 |
"""Select only recovery citations that are actually about the current unit.
|
| 2880 |
|
|
|
|
| 2895 |
tag = str(item.get('tag') or '').strip()
|
| 2896 |
if not tag or tag in excluded or tag in seen:
|
| 2897 |
continue
|
| 2898 |
+
if not self.citation_source_role_eligible(
|
| 2899 |
+
tag, section_id=section_id, section_name=section_name
|
| 2900 |
+
):
|
| 2901 |
+
continue
|
| 2902 |
if not self._citation_recovery_item_relevant(item, section_name, section_context):
|
| 2903 |
continue
|
| 2904 |
overlap, distinctive = self._citation_recovery_overlap(
|
|
|
|
| 2916 |
for tag, meta in self.citation_map.items():
|
| 2917 |
if tag in used:
|
| 2918 |
continue
|
| 2919 |
+
if not self.citation_source_role_eligible(
|
| 2920 |
+
tag, metadata=meta, section_id=section_id, section_name=section_name
|
| 2921 |
+
):
|
| 2922 |
+
continue
|
| 2923 |
evidence = re.sub(r'\s+', ' ', str(meta.get('evidence') or '')).strip()
|
| 2924 |
claim = self._best_contextual_evidence_claim(
|
| 2925 |
evidence, section_name=section_name, section_context=section_context
|
|
|
|
| 2966 |
return ' '.join(selected[:5]) if len(selected) >= 3 else ''
|
| 2967 |
|
| 2968 |
def fetch_contextual_jit_recovery_items(
|
| 2969 |
+
self, *, topic, section_name, section_context, section_id='', needed=1, excluded_tags=None,
|
| 2970 |
):
|
| 2971 |
"""Bounded last-resort JIT top-up for a genuine section-specific citation gap.
|
| 2972 |
|
|
|
|
| 3026 |
tag = f"[CITE-{len(self.citation_map)+1:02d}]"
|
| 3027 |
if tag in excluded:
|
| 3028 |
continue
|
| 3029 |
+
metadata = {
|
| 3030 |
'in_text': res.get('in_text', ''),
|
| 3031 |
'ref': res.get('ref', ''),
|
| 3032 |
'id': keys[0] if keys else ('title_year', res.get('title'), res.get('year')),
|
|
|
|
| 3036 |
'year': res.get('year', ''),
|
| 3037 |
'evidence': evidence,
|
| 3038 |
'sources': [res.get('source', 'jit')],
|
| 3039 |
+
'evidence_lanes': ['contextual_jit'],
|
| 3040 |
}
|
| 3041 |
+
if not self.citation_source_role_eligible(
|
| 3042 |
+
tag, metadata=metadata, section_id=section_id, section_name=section_name
|
| 3043 |
+
):
|
| 3044 |
+
continue
|
| 3045 |
+
self.citation_map[tag] = metadata
|
| 3046 |
selected.append({'tag': tag, 'claim': claim, 'evidence': evidence[:900]})
|
| 3047 |
if len(selected) >= needed:
|
| 3048 |
break
|
|
|
|
| 4482 |
recovered = re.sub(r'[ \t]+\n', '\n', recovered)
|
| 4483 |
return re.sub(r'\n{3,}', '\n\n', recovered).strip()
|
| 4484 |
|
| 4485 |
+
def reinforce_claim_specific_citation_coverage(self, text, assigned_tags, section_name='', section_id=''):
|
| 4486 |
"""Add assigned tags only to citation-free claims with lexical evidence overlap.
|
| 4487 |
|
| 4488 |
This pass is deterministic and makes no additional model call. It never
|
|
|
|
| 4492 |
final authority and may still reject an unsupported placement.
|
| 4493 |
"""
|
| 4494 |
value = str(text or '').strip()
|
| 4495 |
+
valid_tags = [
|
| 4496 |
+
tag for tag in dict.fromkeys(assigned_tags or [])
|
| 4497 |
+
if tag in self.citation_map and self.citation_source_role_eligible(
|
| 4498 |
+
tag, section_id=section_id, section_name=section_name
|
| 4499 |
+
)
|
| 4500 |
+
]
|
| 4501 |
if not value or not valid_tags:
|
| 4502 |
return value
|
| 4503 |
existing = {f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, flags=re.I)}
|