mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-09 00:47:59 +08:00
Refactor: check the evidences while formalize answer. (#17951)
### Summary Check the evidences while formalize answer.
This commit is contained in:
@@ -43,6 +43,30 @@ from rag.prompts.generator import form_message, kb_prompt, message_fit_in
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
_ANSWER_TARGET_SYSTEM = """You are an answer-target verifier for a multi-hop RAG system.
|
||||
Resolve what entity, value, or fact the user's question is asking for.
|
||||
Do not choose bridge entities that merely explain a clue. In inverse-relation
|
||||
questions, a later clue may identify the target's partner, relative, employer,
|
||||
team, or source; keep tracing until the requested answer role is satisfied.
|
||||
Return JSON only."""
|
||||
|
||||
_ANSWER_TARGET_USER = """Question:
|
||||
{question}
|
||||
|
||||
Research summary:
|
||||
{pre_summary}
|
||||
|
||||
Evidence:
|
||||
{evidence}
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"target_role": "the role the final answer must satisfy",
|
||||
"must_satisfy": ["short evidence-backed condition the answer must meet"],
|
||||
"bridge_entities": ["entities that are useful clues but should not be the answer unless they also satisfy target_role"],
|
||||
"reason": "one short explanation of the answer shape"
|
||||
}}"""
|
||||
|
||||
|
||||
def _snip(value: Any, limit: int = 240) -> str:
|
||||
try:
|
||||
@@ -55,6 +79,79 @@ def _snip(value: Any, limit: int = 240) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def _extract_json(text: str) -> dict:
|
||||
text = re.sub(r"^.*</think>", "", text or "", flags=re.DOTALL).strip()
|
||||
text = re.sub(r"```(?:json)?\s*|\s*```", "", text).strip()
|
||||
try:
|
||||
import json_repair
|
||||
|
||||
return json_repair.loads(text)
|
||||
except Exception:
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _compact_text(text: str, limit: int) -> str:
|
||||
text = text or ""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit] + f"\n...(+{len(text) - limit} chars)"
|
||||
|
||||
|
||||
def _string_items(value) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [value.strip()] if value.strip() else []
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(v).strip() for v in value if str(v).strip()]
|
||||
|
||||
|
||||
def _format_answer_target_contract(contract: dict) -> str:
|
||||
target_role = str(contract.get("target_role") or "").strip()
|
||||
reason = str(contract.get("reason") or "").strip()
|
||||
lines = []
|
||||
if target_role:
|
||||
lines.append(f"Final answer must satisfy: {target_role}")
|
||||
must = _string_items(contract.get("must_satisfy"))
|
||||
if must:
|
||||
lines.append("Must meet these conditions:")
|
||||
lines.extend(f"- {item}" for item in must)
|
||||
bridges = _string_items(contract.get("bridge_entities"))
|
||||
if bridges:
|
||||
lines.append("Bridge entities to verify but not answer with:")
|
||||
lines.extend(f"- {item}" for item in bridges)
|
||||
if reason:
|
||||
lines.append(f"Reasoning guard: {reason}")
|
||||
lines.append("Do not answer with an intermediate clue entity unless it also satisfies the final answer role.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _answer_target_contract(tools, question: str, kbinfos: dict, evidence: str) -> str:
|
||||
"""Build a compact guardrail that tells final synthesis what to answer."""
|
||||
fallback = "Final answer must directly satisfy the user's top-level who/what request. Use bridge entities only as clues, and verify any proposed answer against the evidence."
|
||||
pre_summary = kbinfos.get("pre_summary") or ""
|
||||
if not question or not pre_summary:
|
||||
return fallback
|
||||
try:
|
||||
user = _ANSWER_TARGET_USER.format(
|
||||
question=question,
|
||||
pre_summary=_compact_text(pre_summary, 2400),
|
||||
evidence=_compact_text(evidence, 6000),
|
||||
)
|
||||
msg = await tools._fit_messages(_ANSWER_TARGET_SYSTEM, user)
|
||||
ans = await tools.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.0})
|
||||
if isinstance(ans, tuple):
|
||||
ans = ans[0]
|
||||
parsed = _extract_json(ans)
|
||||
formatted = _format_answer_target_contract(parsed) if parsed else ""
|
||||
return formatted or fallback
|
||||
except Exception:
|
||||
_LOG.exception("[Composing the answer] Failed to build answer-target contract")
|
||||
return fallback
|
||||
|
||||
|
||||
class AgenticState(TypedDict, total=False):
|
||||
messages: list
|
||||
question: str
|
||||
@@ -70,6 +167,7 @@ class AgenticState(TypedDict, total=False):
|
||||
empty_result: bool
|
||||
final_answer: str
|
||||
loop: int
|
||||
max_loops: int
|
||||
feedback: str # replanning feedback
|
||||
|
||||
|
||||
@@ -263,9 +361,14 @@ def build_agentic_graph(tools, token_queue: asyncio.Queue, gen_conf: dict | None
|
||||
return {"final_answer": tools.empty_response}
|
||||
|
||||
# Build evidence
|
||||
evidence = kb_prompt(kbinfos, tools.chat_mdl.max_length)
|
||||
evidence_blocks = kb_prompt(kbinfos, tools.chat_mdl.max_length)
|
||||
evidence = "\n".join(evidence_blocks) if isinstance(evidence_blocks, list) else str(evidence_blocks)
|
||||
parts = [f"Question:\n{question}\n"]
|
||||
|
||||
answer_target = await _answer_target_contract(tools, question, kbinfos, evidence)
|
||||
if answer_target:
|
||||
parts.append(f"Answer Target Contract:\n{answer_target}\n")
|
||||
|
||||
if no_evidence:
|
||||
parts.append("No supporting evidence was retrieved. State clearly that the available sources are insufficient, and do not answer from general knowledge.\n")
|
||||
|
||||
@@ -335,7 +438,7 @@ async def run_agentic_rag(tools, messages: list, max_loops: int = 3, gen_conf: d
|
||||
async def _drive():
|
||||
try:
|
||||
holder["state"] = await graph.ainvoke(
|
||||
{"messages": messages},
|
||||
{"messages": messages, "max_loops": max_loops},
|
||||
{"recursion_limit": max(25, max_loops * 8)},
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -249,13 +249,25 @@ def _merge_agent_results(ctx: OrchestratorContext, tools):
|
||||
if combined:
|
||||
tools.kbinfos["pre_summary"] = "\n\n".join(combined)
|
||||
|
||||
# Collect evidence chunks from agent results
|
||||
# Collect the chunks the agents actually cited across all claims. These
|
||||
# indices share the same positional space as kb_prompt's ``[ID:n]`` markers
|
||||
# (both index tools.kbinfos["chunks"]).
|
||||
for c in ctx.claims:
|
||||
if c.agent_result and c.agent_result.evidence_ids:
|
||||
for eid in c.agent_result.evidence_ids:
|
||||
if eid not in seen_evidence:
|
||||
if isinstance(eid, int):
|
||||
seen_evidence.add(eid)
|
||||
|
||||
# Drop chunks no claim ever cited (e.g. pre_search recall that didn't pan
|
||||
# out) so the final-answer LLM call only sees the useful evidence. Preserve
|
||||
# order so the re-numbered [ID:n] citations stay stable. Defensive: never
|
||||
# filter to empty — if nothing was cited, keep the full pool.
|
||||
all_chunks = tools.kbinfos.get("chunks") or []
|
||||
keep = sorted(i for i in seen_evidence if 0 <= i < len(all_chunks))
|
||||
if keep and len(keep) < len(all_chunks):
|
||||
_LOG.info("[Agentic research] Trimming evidence for the final answer: %d of %d chunk(s) were cited.", len(keep), len(all_chunks))
|
||||
tools.kbinfos["chunks"] = [all_chunks[i] for i in keep]
|
||||
|
||||
|
||||
async def _get_compilation_map(tools) -> dict[str, set[str]]:
|
||||
"""Build compilation map from RAGTools - check which KBs have compilation artifacts."""
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Medium mode: decompose → parallel search → sufficiency check."""
|
||||
"""Medium mode: decompose -> parallel search -> evidence-guided follow-up."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from rag.advanced_rag.harness.types import ClaimTarget, AgentResult, OrchestratorContext
|
||||
from rag.advanced_rag.harness.config import get_mode
|
||||
@@ -14,84 +16,433 @@ from rag.advanced_rag.harness.tools.search import hybrid_search
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
_MAX_EVIDENCE_SNIPPETS = 6
|
||||
_MAX_NEXT_QUERIES = 3
|
||||
|
||||
_EVIDENCE_ANALYSIS_SYSTEM = """You are controlling a multi-hop RAG retrieval loop.
|
||||
Judge whether the retrieved passages verify the claim using only the provided evidence.
|
||||
If the claim is not verified, produce targeted next search queries that use entities,
|
||||
dates, names, or relationships discovered in the evidence and move closer to the
|
||||
original question.
|
||||
Distinguish final-answer entities from bridge entities. If the passages identify
|
||||
only a clue node in the chain, keep the claim incomplete and search for the
|
||||
remaining relation needed by the original question. Return JSON only."""
|
||||
|
||||
_EVIDENCE_ANALYSIS_USER = """Original question:
|
||||
{question}
|
||||
|
||||
Claim to verify:
|
||||
{claim}
|
||||
|
||||
Search query used this round:
|
||||
{query}
|
||||
|
||||
Round: {cycle} of {max_cycles}
|
||||
|
||||
Retrieved evidence snippets:
|
||||
{evidence}
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"is_verified": true,
|
||||
"confidence": 0.0,
|
||||
"report": "Short evidence-backed finding, or what was learned so far.",
|
||||
"gaps": ["specific missing fact or relationship"],
|
||||
"next_queries": ["standalone follow-up search query"]
|
||||
}}"""
|
||||
|
||||
|
||||
async def decompose_and_search(state: dict, tools) -> dict:
|
||||
"""Decompose → parallel search → merge → sufficiency check → iterate."""
|
||||
"""Decompose, retrieve, analyze evidence, then iterate with next-hop queries."""
|
||||
question = state.get("question", "")
|
||||
keywords = state.get("keywords", "")
|
||||
claims_raw = state.get("claims", [])
|
||||
mode_label = state.get("route", {}).thinking_mode if state.get("route") else "medium"
|
||||
route = state.get("route")
|
||||
mode_label = _mode_label(route)
|
||||
mode = get_mode(mode_label)
|
||||
max_cycles = _cycle_budget(state, mode.max_orchestrator_cycles)
|
||||
|
||||
claims = [ClaimTarget(**c) if isinstance(c, dict) else c for c in claims_raw]
|
||||
ctx = OrchestratorContext(question=question, claims=claims, mode=mode_label)
|
||||
attempted_queries: dict[str, set[str]] = {c.claim_id: set() for c in ctx.claims}
|
||||
pending_queries: dict[str, list[str]] = {c.claim_id: [] for c in ctx.claims}
|
||||
completed_cycles = 0
|
||||
|
||||
for cycle in range(mode.max_orchestrator_cycles):
|
||||
for cycle in range(max_cycles):
|
||||
ctx.iteration = cycle
|
||||
unverified = [c for c in ctx.claims if not c.is_verified]
|
||||
if not unverified:
|
||||
break
|
||||
|
||||
# Parallel search on unverified claims
|
||||
tasks = []
|
||||
for c in unverified:
|
||||
tasks.append(hybrid_search(tools, query=c.description, keywords=keywords))
|
||||
results = await asyncio.gather(*tasks)
|
||||
_LOG.info(
|
||||
"[Decompose search] Round %d of %d: researching %d unresolved claim(s).",
|
||||
cycle + 1,
|
||||
max_cycles,
|
||||
len(unverified),
|
||||
)
|
||||
|
||||
for c, result in zip(unverified, results):
|
||||
if result.get("chunks"):
|
||||
_merge_kbinfos(tools, result)
|
||||
c.is_verified = True
|
||||
c.confidence = 0.8
|
||||
c.agent_result = AgentResult(
|
||||
claim_id=c.claim_id,
|
||||
report=_summarize(result),
|
||||
is_verified=True,
|
||||
confidence=0.8,
|
||||
evidence_ids=_global_evidence_ids(tools, result),
|
||||
tasks = []
|
||||
searched_claims = []
|
||||
for c in unverified:
|
||||
query = _pick_next_query(
|
||||
question,
|
||||
c,
|
||||
attempted_queries.setdefault(c.claim_id, set()),
|
||||
pending_queries.setdefault(c.claim_id, []),
|
||||
)
|
||||
if not query:
|
||||
_LOG.info("[Decompose search] No unused follow-up query remains for claim %s.", c.claim_id)
|
||||
continue
|
||||
attempted_queries[c.claim_id].add(_normalize_query(query))
|
||||
searched_claims.append((c, query))
|
||||
tasks.append(hybrid_search(tools, query=query, keywords=keywords, use_compiled=True))
|
||||
|
||||
if not tasks:
|
||||
break
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
analysis_inputs = []
|
||||
|
||||
for (c, query), result in zip(searched_claims, results):
|
||||
if isinstance(result, Exception):
|
||||
_LOG.exception("[Decompose search] Search failed for claim %s.", c.claim_id, exc_info=result)
|
||||
result = {"chunks": [], "doc_aggs": []}
|
||||
|
||||
chunks = result.get("chunks", []) or []
|
||||
_merge_kbinfos(tools, result)
|
||||
evidence_ids = _evidence_ids(tools, chunks)
|
||||
analysis_inputs.append((c, query, result, evidence_ids))
|
||||
|
||||
analyses = await asyncio.gather(
|
||||
*[
|
||||
_analyze_claim_evidence(
|
||||
question=question,
|
||||
claim=c,
|
||||
query=query,
|
||||
result=result,
|
||||
evidence_ids=evidence_ids,
|
||||
cycle=cycle,
|
||||
max_cycles=max_cycles,
|
||||
tools=tools,
|
||||
)
|
||||
else:
|
||||
c.agent_result = AgentResult(
|
||||
claim_id=c.claim_id,
|
||||
report="",
|
||||
is_verified=False,
|
||||
confidence=0.0,
|
||||
for c, query, result, evidence_ids in analysis_inputs
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for (c, query, result, evidence_ids), analysis in zip(analysis_inputs, analyses):
|
||||
if isinstance(analysis, Exception):
|
||||
_LOG.exception("[Decompose search] Evidence analysis failed for claim %s.", c.claim_id, exc_info=analysis)
|
||||
analysis = _fallback_analysis(result, cycle, max_cycles)
|
||||
|
||||
c.is_verified = analysis["is_verified"]
|
||||
c.confidence = analysis["confidence"]
|
||||
c.agent_result = AgentResult(
|
||||
claim_id=c.claim_id,
|
||||
report=analysis["report"],
|
||||
is_verified=c.is_verified,
|
||||
confidence=c.confidence,
|
||||
evidence_ids=evidence_ids,
|
||||
gaps=analysis["gaps"],
|
||||
discovered_claims=[],
|
||||
)
|
||||
ctx.agent_results[c.claim_id] = c.agent_result
|
||||
|
||||
next_queries = _new_queries(
|
||||
analysis.get("next_queries", []),
|
||||
attempted_queries.setdefault(c.claim_id, set()),
|
||||
)
|
||||
if not c.is_verified and next_queries:
|
||||
pending_queries.setdefault(c.claim_id, []).extend(next_queries)
|
||||
_LOG.info(
|
||||
"[Decompose search] Claim %s needs another hop; queued %d targeted query/queries.",
|
||||
c.claim_id,
|
||||
len(next_queries),
|
||||
)
|
||||
_LOG.info(
|
||||
'[Decompose search] Claim %s after "%s": %s (confidence %.0f%%).',
|
||||
c.claim_id,
|
||||
_snip(query),
|
||||
"verified" if c.is_verified else "still incomplete",
|
||||
c.confidence * 100,
|
||||
)
|
||||
|
||||
completed_cycles = cycle + 1
|
||||
|
||||
all_chunks = {i: c for i, c in enumerate(tools.kbinfos.get("chunks", []))}
|
||||
agent_results = [c.agent_result for c in ctx.claims if c.agent_result]
|
||||
cross_results = [cross_check_claim(r, all_chunks) for r in agent_results]
|
||||
|
||||
verdict = compute_fusion_score(agent_results, cross_results, mode)
|
||||
ctx.verdict = verdict
|
||||
|
||||
action, should_continue = route_sufficiency_verdict(
|
||||
verdict,
|
||||
mode_label,
|
||||
cycle,
|
||||
mode.max_orchestrator_cycles,
|
||||
max_cycles,
|
||||
)
|
||||
|
||||
if action in ("ANSWER", "ANSWER_PARTIAL"):
|
||||
return {
|
||||
"verdict": verdict.__dict__,
|
||||
"partial_answer": action == "ANSWER_PARTIAL",
|
||||
"kbinfos": tools.kbinfos,
|
||||
}
|
||||
return _finalize(ctx, tools, partial=action == "ANSWER_PARTIAL", loop=completed_cycles)
|
||||
if action == "ABSTAIN":
|
||||
if getattr(tools, "text_attachments_content", ""):
|
||||
return {"verdict": verdict.__dict__, "kbinfos": tools.kbinfos}
|
||||
tools.kbinfos["chunks"] = []
|
||||
return {"verdict": verdict.__dict__, "abstain": True}
|
||||
return {"verdict": verdict.__dict__, "abstain": True, "loop": completed_cycles}
|
||||
if action == "FALLBACK_LLM":
|
||||
return _finalize(ctx, tools, partial=True, loop=completed_cycles)
|
||||
if not should_continue:
|
||||
break
|
||||
|
||||
# Cycle exhaustion: flag the answer as partial so the final-answer node
|
||||
# prepends the partial-answer preamble instead of presenting an incomplete
|
||||
# answer as complete. Partial when (a) some claim is still unverified, or
|
||||
# (b) every claim is verified but the final verdict is not SUFFICIENT (e.g.
|
||||
# cross-check flagged conflicts/mismatches) — in both cases an exhaustive
|
||||
# answer was not reached.
|
||||
verdict_status = getattr(verdict, "status", None)
|
||||
partial = (any(not c.is_verified for c in ctx.claims) or (verdict_status is not None and verdict_status != "SUFFICIENT")) and bool(tools.kbinfos.get("chunks"))
|
||||
return {"kbinfos": tools.kbinfos, "partial_answer": partial}
|
||||
if not tools.kbinfos.get("chunks"):
|
||||
return {"empty_result": True, "kbinfos": tools.kbinfos, "loop": completed_cycles}
|
||||
|
||||
partial = any(not c.is_verified for c in ctx.claims)
|
||||
return _finalize(ctx, tools, partial=partial, loop=completed_cycles)
|
||||
|
||||
|
||||
async def _analyze_claim_evidence(
|
||||
*,
|
||||
question: str,
|
||||
claim: ClaimTarget,
|
||||
query: str,
|
||||
result: dict,
|
||||
evidence_ids: list[int],
|
||||
cycle: int,
|
||||
max_cycles: int,
|
||||
tools,
|
||||
) -> dict:
|
||||
chunks = result.get("chunks", []) or []
|
||||
if not chunks:
|
||||
return {
|
||||
"is_verified": False,
|
||||
"confidence": 0.0,
|
||||
"report": "",
|
||||
"gaps": ["no evidence found"],
|
||||
"next_queries": _fallback_queries(question, claim),
|
||||
}
|
||||
|
||||
try:
|
||||
user = _EVIDENCE_ANALYSIS_USER.format(
|
||||
question=question,
|
||||
claim=claim.description,
|
||||
query=query,
|
||||
cycle=cycle + 1,
|
||||
max_cycles=max_cycles,
|
||||
evidence=_format_evidence(chunks),
|
||||
)
|
||||
msg = await tools._fit_messages(_EVIDENCE_ANALYSIS_SYSTEM, user)
|
||||
ans = await tools.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.1})
|
||||
if isinstance(ans, tuple):
|
||||
ans = ans[0]
|
||||
parsed = _extract_json(ans)
|
||||
return _normalize_analysis(parsed, result, evidence_ids, question, claim, cycle, max_cycles)
|
||||
except Exception:
|
||||
_LOG.exception("[Decompose search] Evidence analysis LLM call failed.")
|
||||
return _fallback_analysis(result, cycle, max_cycles, question, claim)
|
||||
|
||||
|
||||
def _mode_label(route) -> str:
|
||||
if not route:
|
||||
return "medium"
|
||||
if isinstance(route, dict):
|
||||
return route.get("thinking_mode", "medium")
|
||||
return getattr(route, "thinking_mode", "medium")
|
||||
|
||||
|
||||
def _cycle_budget(state: dict, default_cycles: int) -> int:
|
||||
try:
|
||||
requested = int(state.get("max_loops") or default_cycles)
|
||||
except (TypeError, ValueError):
|
||||
requested = default_cycles
|
||||
return max(1, min(default_cycles, requested))
|
||||
|
||||
|
||||
def _extract_json(text: str) -> dict:
|
||||
text = re.sub(r"^.*</think>", "", text or "", flags=re.DOTALL).strip()
|
||||
text = re.sub(r"```(?:json)?\s*|\s*```", "", text).strip()
|
||||
try:
|
||||
import json_repair
|
||||
|
||||
return json_repair.loads(text)
|
||||
except Exception:
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
_LOG.warning("[Decompose search] Failed to parse evidence analysis output: %s", text[:200])
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_analysis(
|
||||
parsed: dict,
|
||||
result: dict,
|
||||
evidence_ids: list[int],
|
||||
question: str,
|
||||
claim: ClaimTarget,
|
||||
cycle: int,
|
||||
max_cycles: int,
|
||||
) -> dict:
|
||||
confidence = _clamp_float(parsed.get("confidence"), 0.0, 1.0)
|
||||
is_verified = bool(parsed.get("is_verified")) and bool(evidence_ids) and confidence >= 0.55
|
||||
report = str(parsed.get("report") or "").strip() or _summarize(result)
|
||||
gaps = _string_list(parsed.get("gaps"))
|
||||
next_queries = _string_list(parsed.get("next_queries"))[:_MAX_NEXT_QUERIES]
|
||||
|
||||
if is_verified:
|
||||
gaps = []
|
||||
next_queries = []
|
||||
confidence = max(confidence, 0.65)
|
||||
elif not next_queries and cycle + 1 < max_cycles:
|
||||
next_queries = _fallback_queries(question, claim)
|
||||
|
||||
return {
|
||||
"is_verified": is_verified,
|
||||
"confidence": confidence,
|
||||
"report": report,
|
||||
"gaps": gaps,
|
||||
"next_queries": next_queries,
|
||||
}
|
||||
|
||||
|
||||
def _fallback_analysis(
|
||||
result: dict,
|
||||
cycle: int,
|
||||
max_cycles: int,
|
||||
question: str = "",
|
||||
claim: ClaimTarget | None = None,
|
||||
) -> dict:
|
||||
chunks = result.get("chunks", []) or []
|
||||
is_last_cycle = cycle + 1 >= max_cycles
|
||||
is_verified = bool(chunks) and is_last_cycle
|
||||
next_queries = [] if is_last_cycle or claim is None else _fallback_queries(question, claim)
|
||||
return {
|
||||
"is_verified": is_verified,
|
||||
"confidence": 0.55 if is_verified else (0.35 if chunks else 0.0),
|
||||
"report": _summarize(result),
|
||||
"gaps": [] if is_verified else ["need more specific evidence"],
|
||||
"next_queries": next_queries,
|
||||
}
|
||||
|
||||
|
||||
def _pick_next_query(
|
||||
question: str,
|
||||
claim: ClaimTarget,
|
||||
attempted: set[str],
|
||||
pending: list[str],
|
||||
) -> str:
|
||||
while pending:
|
||||
query = (pending.pop(0) or "").strip()
|
||||
normalized = _normalize_query(query)
|
||||
if normalized and normalized not in attempted:
|
||||
return query
|
||||
|
||||
candidates = []
|
||||
if not attempted:
|
||||
candidates.append(claim.description)
|
||||
candidates.extend(_fallback_queries(question, claim))
|
||||
|
||||
for query in candidates:
|
||||
query = (query or "").strip()
|
||||
normalized = _normalize_query(query)
|
||||
if normalized and normalized not in attempted:
|
||||
return query
|
||||
return ""
|
||||
|
||||
|
||||
def _fallback_queries(question: str, claim: ClaimTarget) -> list[str]:
|
||||
candidates = []
|
||||
for gap in _agent_result_gaps(claim.agent_result):
|
||||
candidates.append(f"{claim.description} {gap}")
|
||||
if question:
|
||||
candidates.append(f"{question} {claim.description}")
|
||||
candidates.append(claim.description)
|
||||
return candidates[:_MAX_NEXT_QUERIES]
|
||||
|
||||
|
||||
def _new_queries(raw_queries: list[str], attempted: set[str]) -> list[str]:
|
||||
queries = []
|
||||
seen = set(attempted)
|
||||
for query in raw_queries:
|
||||
query = (query or "").strip()
|
||||
normalized = _normalize_query(query)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
queries.append(query)
|
||||
if len(queries) >= _MAX_NEXT_QUERIES:
|
||||
break
|
||||
return queries
|
||||
|
||||
|
||||
def _agent_result_gaps(agent_result) -> list[str]:
|
||||
if not agent_result:
|
||||
return []
|
||||
if isinstance(agent_result, dict):
|
||||
return _string_list(agent_result.get("gaps"))
|
||||
return _string_list(getattr(agent_result, "gaps", []))
|
||||
|
||||
|
||||
def _normalize_query(query: str) -> str:
|
||||
return " ".join((query or "").lower().split())
|
||||
|
||||
|
||||
def _format_evidence(chunks: list[dict]) -> str:
|
||||
snippets = []
|
||||
for i, chunk in enumerate(chunks[:_MAX_EVIDENCE_SNIPPETS], start=1):
|
||||
text = chunk.get("content_with_weight") or chunk.get("content") or chunk.get("text") or ""
|
||||
source = chunk.get("docnm_kwd") or chunk.get("doc_name") or chunk.get("doc_id") or "source"
|
||||
snippets.append(f"[{i}] {source}: {_snip(text, 900)}")
|
||||
return "\n\n".join(snippets) or "(no evidence)"
|
||||
|
||||
|
||||
def _string_list(value) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [value.strip()] if value.strip() else []
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(v).strip() for v in value if str(v).strip()]
|
||||
|
||||
|
||||
def _clamp_float(value, lo: float, hi: float) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
number = 0.0
|
||||
return min(hi, max(lo, number))
|
||||
|
||||
|
||||
def _evidence_ids(tools, chunks: list[dict]) -> list[int]:
|
||||
all_chunks = tools.kbinfos.get("chunks", [])
|
||||
index_by_key = {_chunk_key(c): i for i, c in enumerate(all_chunks)}
|
||||
ids = []
|
||||
for chunk in chunks:
|
||||
idx = index_by_key.get(_chunk_key(chunk))
|
||||
if idx is not None and idx not in ids:
|
||||
ids.append(idx)
|
||||
return ids
|
||||
|
||||
|
||||
def _finalize(ctx: OrchestratorContext, tools, partial: bool, loop: int) -> dict:
|
||||
combined = []
|
||||
for claim in ctx.claims:
|
||||
if claim.agent_result and claim.agent_result.report:
|
||||
status = "verified" if claim.is_verified else "incomplete"
|
||||
combined.append(f"[{claim.claim_id}] {status} ({claim.description}): {claim.agent_result.report[:500]}")
|
||||
if combined:
|
||||
tools.kbinfos["pre_summary"] = "Research findings. These may include bridge entities; the final answer must still satisfy the original question's requested role.\n\n" + "\n\n".join(combined)
|
||||
|
||||
return {
|
||||
"verdict": ctx.verdict.__dict__ if ctx.verdict else None,
|
||||
"partial_answer": partial,
|
||||
"kbinfos": tools.kbinfos,
|
||||
"loop": loop,
|
||||
}
|
||||
|
||||
|
||||
def _snip(text: str, limit: int = 160) -> str:
|
||||
text = (text or "").replace("\n", " ").strip()
|
||||
return text if len(text) <= limit else text[: limit - 3] + "..."
|
||||
|
||||
|
||||
def _merge_kbinfos(tools, result: dict):
|
||||
@@ -136,5 +487,5 @@ def _global_evidence_ids(tools, result: dict) -> list[int]:
|
||||
|
||||
def _summarize(result: dict) -> str:
|
||||
chunks = result.get("chunks", [])
|
||||
texts = [c.get("content_with_weight", "")[:200] for c in chunks[:3]]
|
||||
texts = [(c.get("content_with_weight") or c.get("content") or c.get("text") or "")[:200] for c in chunks[:3]]
|
||||
return " | ".join(texts)
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
FINAL_ANSWER_SYSTEM = """You are a smart agent. Answer the user's question using ONLY the evidence provided below. Do not invent facts: if the evidence cannot support a claim, say so plainly instead of guessing.
|
||||
|
||||
# Answer target
|
||||
First resolve the exact role requested by the user's question. Multi-hop questions
|
||||
often mention bridge entities that are only clues. Do not answer with a bridge
|
||||
entity just because it satisfies a later clue; answer the entity, value, or fact
|
||||
that satisfies the top-level question. If an Answer Target Contract is provided,
|
||||
obey it over any research-summary wording.
|
||||
|
||||
# Citation rules
|
||||
{cite_rules}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ def _highlight_keywords(text: str, kwds: list[str]) -> str:
|
||||
if not terms:
|
||||
return text
|
||||
pattern = re.compile("|".join(re.escape(term) for term in terms), re.IGNORECASE)
|
||||
return pattern.sub(lambda m: f"<em>{m.group(0)}</em>", text)
|
||||
return pattern.sub(lambda m: f"*{m.group(0)}*", text)
|
||||
|
||||
|
||||
def _narrow_by_keywords(chunks: list[dict], keywords: str) -> list[dict]:
|
||||
|
||||
Reference in New Issue
Block a user