Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8be4e4831 | ||
|
|
90033214b4 |
@@ -30,3 +30,7 @@ Rplots.pdf
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.log
|
*.log
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.worktrees/
|
||||||
|
extension_stages_lightweight/
|
||||||
|
services/*/logs/
|
||||||
|
*.bak_*
|
||||||
|
|||||||
+282
-23
@@ -25,6 +25,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Sequence
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
if str(PROJECT_ROOT) not in sys.path:
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
@@ -231,8 +232,17 @@ RNASEQ_BIN = str(rnaseq_bin())
|
|||||||
prepend_path(RNASEQ_BIN)
|
prepend_path(RNASEQ_BIN)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_TOOL_THREADS = 8
|
||||||
|
|
||||||
|
|
||||||
def resolve_thread_plan(cpu: int, sample_parallel_jobs: int = 1, threads_per_sample: Optional[int] = None) -> Dict[str, int]:
|
def resolve_thread_plan(cpu: int, sample_parallel_jobs: int = 1, threads_per_sample: Optional[int] = None) -> Dict[str, int]:
|
||||||
"""Resolve conservative RNA-seq sample/thread settings from CLI args."""
|
"""Resolve conservative RNA-seq sample/thread settings from CLI args.
|
||||||
|
|
||||||
|
Per-tool thread count is capped at MAX_TOOL_THREADS because fastp (and most
|
||||||
|
QC tools) are single-threaded for their core algorithm; oversized --thread
|
||||||
|
values only add gzip-thread contention without speeding up filtering. Real
|
||||||
|
parallelism is achieved via chunked splitting (see run_fastp).
|
||||||
|
"""
|
||||||
total_cpu = max(1, int(cpu or 1))
|
total_cpu = max(1, int(cpu or 1))
|
||||||
jobs = max(1, int(sample_parallel_jobs or 1))
|
jobs = max(1, int(sample_parallel_jobs or 1))
|
||||||
if threads_per_sample is None or int(threads_per_sample) < 1:
|
if threads_per_sample is None or int(threads_per_sample) < 1:
|
||||||
@@ -241,6 +251,7 @@ def resolve_thread_plan(cpu: int, sample_parallel_jobs: int = 1, threads_per_sam
|
|||||||
threads = max(1, int(threads_per_sample))
|
threads = max(1, int(threads_per_sample))
|
||||||
if jobs * threads > total_cpu:
|
if jobs * threads > total_cpu:
|
||||||
threads = max(1, total_cpu // jobs)
|
threads = max(1, total_cpu // jobs)
|
||||||
|
threads = min(threads, MAX_TOOL_THREADS)
|
||||||
return {"cpu": total_cpu, "sample_parallel_jobs": jobs, "threads_per_sample": threads}
|
return {"cpu": total_cpu, "sample_parallel_jobs": jobs, "threads_per_sample": threads}
|
||||||
|
|
||||||
|
|
||||||
@@ -378,6 +389,253 @@ def run_cutadapt(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _build_fastp_cmd(
|
||||||
|
fastp_bin: Path,
|
||||||
|
r1: Path,
|
||||||
|
r2: Path,
|
||||||
|
out_r1: Path,
|
||||||
|
out_r2: Path,
|
||||||
|
html_report: Path,
|
||||||
|
json_report: Path,
|
||||||
|
cpu: int,
|
||||||
|
detect_adapter_for_pe: bool,
|
||||||
|
cut_front: bool,
|
||||||
|
cut_tail: bool,
|
||||||
|
cut_window_size: int,
|
||||||
|
cut_mean_quality: int,
|
||||||
|
qualified_quality_phred: int,
|
||||||
|
unqualified_percent_limit: int,
|
||||||
|
n_base_limit: int,
|
||||||
|
length_required: int,
|
||||||
|
) -> List[str]:
|
||||||
|
"""Build a fastp command line with a capped --thread value."""
|
||||||
|
cmd = [
|
||||||
|
str(fastp_bin),
|
||||||
|
"-i", str(r1),
|
||||||
|
"-I", str(r2),
|
||||||
|
"-o", str(out_r1),
|
||||||
|
"-O", str(out_r2),
|
||||||
|
"--html", str(html_report),
|
||||||
|
"--json", str(json_report),
|
||||||
|
"--thread", str(max(1, min(MAX_TOOL_THREADS, int(cpu or 1)))),
|
||||||
|
"--cut_window_size", str(cut_window_size),
|
||||||
|
"--cut_mean_quality", str(cut_mean_quality),
|
||||||
|
"--qualified_quality_phred", str(qualified_quality_phred),
|
||||||
|
"--unqualified_percent_limit", str(unqualified_percent_limit),
|
||||||
|
"--n_base_limit", str(n_base_limit),
|
||||||
|
"--length_required", str(length_required),
|
||||||
|
]
|
||||||
|
if detect_adapter_for_pe:
|
||||||
|
cmd.append("--detect_adapter_for_pe")
|
||||||
|
if cut_front:
|
||||||
|
cmd.append("--cut_front")
|
||||||
|
if cut_tail:
|
||||||
|
cmd.append("--cut_tail")
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def _count_fastq_reads(fastq_gz: Path) -> int:
|
||||||
|
"""Count the number of reads (4 lines each) in a gzipped FASTQ using pigz."""
|
||||||
|
proc = subprocess.run(
|
||||||
|
["bash", "-c", f"pigz -dc {shlex.quote(str(fastq_gz))} | wc -l"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
return int((proc.stdout or "0").strip() or 0) // 4
|
||||||
|
|
||||||
|
|
||||||
|
def _split_fastq_gz(fastq_gz: Path, work_dir: Path, tag: str, n_chunks: int) -> List[Path]:
|
||||||
|
"""Split a gzipped FASTQ into n_chunks contiguous, order-preserving gzipped chunks.
|
||||||
|
|
||||||
|
Uses pigz for parallel (de)compression. Read boundaries (4 lines per read) are
|
||||||
|
preserved so the downstream merge stays in order and R1/R2 stay paired.
|
||||||
|
"""
|
||||||
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
total_reads = _count_fastq_reads(fastq_gz)
|
||||||
|
if total_reads <= 0:
|
||||||
|
raise RuntimeError(f"fastq is empty or unreadable: {fastq_gz}")
|
||||||
|
reads_per_chunk = -(-total_reads // n_chunks) # ceil division
|
||||||
|
lines_per_chunk = max(4, reads_per_chunk * 4)
|
||||||
|
raw_prefix = work_dir / f"{tag}.raw"
|
||||||
|
subprocess.run(
|
||||||
|
["bash", "-c",
|
||||||
|
f"pigz -dc {shlex.quote(str(fastq_gz))} | split -l {lines_per_chunk} -d -a 4 - {shlex.quote(str(raw_prefix))}"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
raw_chunks = sorted(work_dir.glob(f"{tag}.raw[0-9]*"))
|
||||||
|
chunk_paths: List[Path] = []
|
||||||
|
for i, raw in enumerate(raw_chunks):
|
||||||
|
out = work_dir / f"{tag}.part{i:04d}.fastq.gz"
|
||||||
|
with open(out, "wb") as fout:
|
||||||
|
subprocess.run(["pigz", "-p", "4", "-c", str(raw)], stdout=fout, check=True)
|
||||||
|
raw.unlink(missing_ok=True)
|
||||||
|
chunk_paths.append(out)
|
||||||
|
if not chunk_paths:
|
||||||
|
raise RuntimeError(f"failed to split fastq: {fastq_gz}")
|
||||||
|
return chunk_paths
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_fastp_json(chunk_jsons: List[Path]) -> Dict[str, Any]:
|
||||||
|
"""Merge per-chunk fastp JSON summaries into one combined summary.
|
||||||
|
|
||||||
|
Read/base counts are summed; rates, GC content and mean lengths are recomputed
|
||||||
|
as (base|read)-weighted averages so merged stats match a single full run.
|
||||||
|
"""
|
||||||
|
summary: Dict[str, Any] = {"before_filtering": {}, "after_filtering": {}}
|
||||||
|
for section in ("before_filtering", "after_filtering"):
|
||||||
|
total_reads = 0
|
||||||
|
total_bases = 0
|
||||||
|
q20_bases = 0
|
||||||
|
q30_bases = 0
|
||||||
|
gc_sum = 0.0
|
||||||
|
r1_len_sum = 0.0
|
||||||
|
r2_len_sum = 0.0
|
||||||
|
for jf in chunk_jsons:
|
||||||
|
with open(jf, encoding="utf-8") as f:
|
||||||
|
s = json.load(f).get("summary", {}).get(section, {})
|
||||||
|
tr = int(s.get("total_reads", 0) or 0)
|
||||||
|
tb = int(s.get("total_bases", 0) or 0)
|
||||||
|
total_reads += tr
|
||||||
|
total_bases += tb
|
||||||
|
q20_bases += int(s.get("q20_bases", 0) or 0)
|
||||||
|
q30_bases += int(s.get("q30_bases", 0) or 0)
|
||||||
|
gc_sum += float(s.get("gc_content", 0) or 0) * tb
|
||||||
|
r1_len_sum += float(s.get("read1_mean_length", 0) or 0) * tr
|
||||||
|
r2_len_sum += float(s.get("read2_mean_length", 0) or 0) * tr
|
||||||
|
summary[section] = {
|
||||||
|
"total_reads": total_reads,
|
||||||
|
"total_bases": total_bases,
|
||||||
|
"q20_bases": q20_bases,
|
||||||
|
"q30_bases": q30_bases,
|
||||||
|
"q20_rate": (q20_bases / total_bases) if total_bases else 0.0,
|
||||||
|
"q30_rate": (q30_bases / total_bases) if total_bases else 0.0,
|
||||||
|
"gc_content": (gc_sum / total_bases) if total_bases else 0.0,
|
||||||
|
"read1_mean_length": (r1_len_sum / total_reads) if total_reads else 0.0,
|
||||||
|
"read2_mean_length": (r2_len_sum / total_reads) if total_reads else 0.0,
|
||||||
|
}
|
||||||
|
return {"summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_fastp_summary_html(merged: Dict[str, Any], html_report: Path) -> None:
|
||||||
|
"""Write a lightweight self-contained HTML report from the merged summary."""
|
||||||
|
before = merged["summary"]["before_filtering"]
|
||||||
|
after = merged["summary"]["after_filtering"]
|
||||||
|
rows = [
|
||||||
|
("Total reads", before.get("total_reads", 0), after.get("total_reads", 0)),
|
||||||
|
("Q20 rate", f"{before.get('q20_rate', 0):.4f}", f"{after.get('q20_rate', 0):.4f}"),
|
||||||
|
("Q30 rate", f"{before.get('q30_rate', 0):.4f}", f"{after.get('q30_rate', 0):.4f}"),
|
||||||
|
("GC content", f"{before.get('gc_content', 0):.4f}", f"{after.get('gc_content', 0):.4f}"),
|
||||||
|
]
|
||||||
|
trs = "".join(f"<tr><td>{name}</td><td>{b}</td><td>{a}</td></tr>" for name, b, a in rows)
|
||||||
|
html = (
|
||||||
|
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
|
||||||
|
"<title>fastp QC Report (chunked)</title></head><body>"
|
||||||
|
"<h1>fastp QC Report</h1><p>Generated from chunked parallel fastp runs.</p>"
|
||||||
|
"<table border='1' cellpadding='6' cellspacing='0'>"
|
||||||
|
"<tr><th>Metric</th><th>Before filtering</th><th>After filtering</th></tr>"
|
||||||
|
f"{trs}</table></body></html>"
|
||||||
|
)
|
||||||
|
html_report.write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_fastp_chunked(
|
||||||
|
fastp_bin: Path,
|
||||||
|
r1: Path,
|
||||||
|
r2: Path,
|
||||||
|
out_r1: Path,
|
||||||
|
out_r2: Path,
|
||||||
|
html_report: Path,
|
||||||
|
json_report: Path,
|
||||||
|
log_file: Path,
|
||||||
|
cpu: int,
|
||||||
|
chunks: int,
|
||||||
|
chunk_work_dir: Path,
|
||||||
|
detect_adapter_for_pe: bool,
|
||||||
|
cut_front: bool,
|
||||||
|
cut_tail: bool,
|
||||||
|
cut_window_size: int,
|
||||||
|
cut_mean_quality: int,
|
||||||
|
qualified_quality_phred: int,
|
||||||
|
unqualified_percent_limit: int,
|
||||||
|
n_base_limit: int,
|
||||||
|
length_required: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Run fastp in chunked-parallel mode: split FASTQ, parallel fastp, merge outputs."""
|
||||||
|
chunk_work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f" [fastp] Splitting into {chunks} chunks (parallel mode)...", flush=True)
|
||||||
|
start_ts = time.time()
|
||||||
|
|
||||||
|
r1_chunks = _split_fastq_gz(r1, chunk_work_dir, "r1", chunks)
|
||||||
|
r2_chunks = _split_fastq_gz(r2, chunk_work_dir, "r2", chunks)
|
||||||
|
n = max(len(r1_chunks), len(r2_chunks))
|
||||||
|
if len(r1_chunks) != len(r2_chunks):
|
||||||
|
print(
|
||||||
|
f" [fastp] WARNING: R1/R2 chunk counts differ "
|
||||||
|
f"({len(r1_chunks)} vs {len(r2_chunks)}); using {n}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for i in range(n):
|
||||||
|
tasks.append({
|
||||||
|
"idx": i,
|
||||||
|
"r1": r1_chunks[i],
|
||||||
|
"r2": r2_chunks[i],
|
||||||
|
"o1": chunk_work_dir / f"out{i:04d}.r1.fastq.gz",
|
||||||
|
"o2": chunk_work_dir / f"out{i:04d}.r2.fastq.gz",
|
||||||
|
"html": chunk_work_dir / f"out{i:04d}.fastp.html",
|
||||||
|
"json": chunk_work_dir / f"out{i:04d}.fastp.json",
|
||||||
|
})
|
||||||
|
|
||||||
|
def _run_one(t: Dict[str, Any]):
|
||||||
|
cmd = _build_fastp_cmd(
|
||||||
|
fastp_bin, t["r1"], t["r2"], t["o1"], t["o2"], t["html"], t["json"], cpu,
|
||||||
|
detect_adapter_for_pe, cut_front, cut_tail, cut_window_size, cut_mean_quality,
|
||||||
|
qualified_quality_phred, unqualified_percent_limit, n_base_limit, length_required,
|
||||||
|
)
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
if res.returncode != 0:
|
||||||
|
return t["idx"], None, res.stderr
|
||||||
|
for p in (t["o1"], t["o2"], t["json"]):
|
||||||
|
if not p.exists() or p.stat().st_size == 0:
|
||||||
|
return t["idx"], None, f"empty output: {p}"
|
||||||
|
return t["idx"], t, None
|
||||||
|
|
||||||
|
results: Dict[int, Dict[str, Any]] = {}
|
||||||
|
errors: List[str] = []
|
||||||
|
with ThreadPoolExecutor(max_workers=max(1, int(chunks))) as ex:
|
||||||
|
futures = [ex.submit(_run_one, t) for t in tasks]
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
idx, t, err = fut.result()
|
||||||
|
if err:
|
||||||
|
errors.append(f"chunk {idx}: {err}")
|
||||||
|
else:
|
||||||
|
results[idx] = t
|
||||||
|
if errors:
|
||||||
|
raise RuntimeError(f"fastp chunked failed: {'; '.join(errors[:3])}")
|
||||||
|
|
||||||
|
# Merge clean reads in chunk order (preserves read order/pairing)
|
||||||
|
for out_path, key in ((out_r1, "o1"), (out_r2, "o2")):
|
||||||
|
with open(out_path, "wb") as fout:
|
||||||
|
for i in range(n):
|
||||||
|
with open(results[i][key], "rb") as fin:
|
||||||
|
shutil.copyfileobj(fin, fout)
|
||||||
|
|
||||||
|
# Merge JSON + generate HTML summary
|
||||||
|
merged = _merge_fastp_json([results[i]["json"] for i in range(n)])
|
||||||
|
json_report.write_text(json.dumps(merged), encoding="utf-8")
|
||||||
|
_write_fastp_summary_html(merged, html_report)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_ts
|
||||||
|
log_file.write_text(
|
||||||
|
f"fastp chunked parallel mode: {n} chunks x --thread {max(1, min(MAX_TOOL_THREADS, int(cpu or 1)))}\n"
|
||||||
|
f"elapsed: {elapsed:.1f}s\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
shutil.rmtree(chunk_work_dir, ignore_errors=True)
|
||||||
|
print(f" [fastp] Completed (chunked x{n}) in {elapsed:.1f}s", flush=True)
|
||||||
|
return {"success": True, "time_sec": elapsed}
|
||||||
|
|
||||||
|
|
||||||
def run_fastp(
|
def run_fastp(
|
||||||
fastp_bin: Path,
|
fastp_bin: Path,
|
||||||
r1: Path,
|
r1: Path,
|
||||||
@@ -397,30 +655,22 @@ def run_fastp(
|
|||||||
unqualified_percent_limit: int = 30,
|
unqualified_percent_limit: int = 30,
|
||||||
n_base_limit: int = 5,
|
n_base_limit: int = 5,
|
||||||
length_required: int = 30,
|
length_required: int = 30,
|
||||||
|
chunks: int = 1,
|
||||||
|
chunk_work_dir: Optional[Path] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
cmd = [
|
if chunks and int(chunks) > 1 and chunk_work_dir is not None:
|
||||||
str(fastp_bin),
|
return _run_fastp_chunked(
|
||||||
"-i", str(r1),
|
fastp_bin, r1, r2, out_r1, out_r2, html_report, json_report, log_file,
|
||||||
"-I", str(r2),
|
cpu, int(chunks), chunk_work_dir,
|
||||||
"-o", str(out_r1),
|
detect_adapter_for_pe, cut_front, cut_tail, cut_window_size, cut_mean_quality,
|
||||||
"-O", str(out_r2),
|
qualified_quality_phred, unqualified_percent_limit, n_base_limit, length_required,
|
||||||
"--html", str(html_report),
|
)
|
||||||
"--json", str(json_report),
|
|
||||||
"--thread", str(max(1, int(cpu or 1))),
|
|
||||||
"--cut_window_size", str(cut_window_size),
|
|
||||||
"--cut_mean_quality", str(cut_mean_quality),
|
|
||||||
"--qualified_quality_phred", str(qualified_quality_phred),
|
|
||||||
"--unqualified_percent_limit", str(unqualified_percent_limit),
|
|
||||||
"--n_base_limit", str(n_base_limit),
|
|
||||||
"--length_required", str(length_required),
|
|
||||||
]
|
|
||||||
if detect_adapter_for_pe:
|
|
||||||
cmd.append("--detect_adapter_for_pe")
|
|
||||||
if cut_front:
|
|
||||||
cmd.append("--cut_front")
|
|
||||||
if cut_tail:
|
|
||||||
cmd.append("--cut_tail")
|
|
||||||
|
|
||||||
|
cmd = _build_fastp_cmd(
|
||||||
|
fastp_bin, r1, r2, out_r1, out_r2, html_report, json_report, cpu,
|
||||||
|
detect_adapter_for_pe, cut_front, cut_tail, cut_window_size, cut_mean_quality,
|
||||||
|
qualified_quality_phred, unqualified_percent_limit, n_base_limit, length_required,
|
||||||
|
)
|
||||||
print(f" [fastp] Starting: {r1.name} + {r2.name}...", flush=True)
|
print(f" [fastp] Starting: {r1.name} + {r2.name}...", flush=True)
|
||||||
start_ts = time.time()
|
start_ts = time.time()
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
@@ -767,6 +1017,7 @@ def main():
|
|||||||
parser.add_argument("-t", "--cpu", type=int, default=4, help="Total CPU threads for this stage")
|
parser.add_argument("-t", "--cpu", type=int, default=4, help="Total CPU threads for this stage")
|
||||||
parser.add_argument("--sample-parallel-jobs", type=int, default=1, help="Number of samples to process concurrently")
|
parser.add_argument("--sample-parallel-jobs", type=int, default=1, help="Number of samples to process concurrently")
|
||||||
parser.add_argument("--threads-per-sample", type=int, help="Threads used by each per-sample tool invocation")
|
parser.add_argument("--threads-per-sample", type=int, help="Threads used by each per-sample tool invocation")
|
||||||
|
parser.add_argument("--chunks-per-sample", type=int, default=0, help="Split each sample into N chunks for parallel fastp. 0 = auto (cpu // threads-per-sample).")
|
||||||
parser.add_argument("--resume-existing", action="store_true", help="Skip samples whose FastQC, clean reads, and cutadapt outputs already exist in the output directory")
|
parser.add_argument("--resume-existing", action="store_true", help="Skip samples whose FastQC, clean reads, and cutadapt outputs already exist in the output directory")
|
||||||
parser.add_argument("-p", "--prefix", default="", help="Output file prefix")
|
parser.add_argument("-p", "--prefix", default="", help="Output file prefix")
|
||||||
parser.add_argument("--adapter-r1", default="AGATCGGAAGAGC", help="Adapter sequence for read1")
|
parser.add_argument("--adapter-r1", default="AGATCGGAAGAGC", help="Adapter sequence for read1")
|
||||||
@@ -787,6 +1038,12 @@ def main():
|
|||||||
thread_plan = resolve_thread_plan(args.cpu, args.sample_parallel_jobs, args.threads_per_sample)
|
thread_plan = resolve_thread_plan(args.cpu, args.sample_parallel_jobs, args.threads_per_sample)
|
||||||
threads_per_sample = thread_plan["threads_per_sample"]
|
threads_per_sample = thread_plan["threads_per_sample"]
|
||||||
|
|
||||||
|
# Chunked parallelism: split each sample into N chunks and run fastp in
|
||||||
|
# parallel (the real way to saturate many cores, since fastp is single-threaded).
|
||||||
|
chunks_per_sample = int(args.chunks_per_sample or 0)
|
||||||
|
if chunks_per_sample < 1:
|
||||||
|
chunks_per_sample = max(1, int(thread_plan["cpu"]) // max(1, int(threads_per_sample)))
|
||||||
|
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
input_dir = Path(args.input)
|
input_dir = Path(args.input)
|
||||||
output_dir = Path(args.output) # rnaseq_qc 工作目录
|
output_dir = Path(args.output) # rnaseq_qc 工作目录
|
||||||
@@ -878,6 +1135,8 @@ def main():
|
|||||||
json_report=analysis_dir / f"{sample_name}_fastp.json",
|
json_report=analysis_dir / f"{sample_name}_fastp.json",
|
||||||
log_file=analysis_dir / f"{sample_name}_fastp.log",
|
log_file=analysis_dir / f"{sample_name}_fastp.log",
|
||||||
cpu=threads_per_sample,
|
cpu=threads_per_sample,
|
||||||
|
chunks=chunks_per_sample,
|
||||||
|
chunk_work_dir=analysis_dir / f".{sample_name}.chunks",
|
||||||
**fastp_params,
|
**fastp_params,
|
||||||
)
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
|
|||||||
+22
-16
@@ -22911,11 +22911,20 @@ compare: [[flesh, peel]]`;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const readRagEventStream = async (url, options = {}, timeoutMs = 300000) => {
|
const readRagEventStream = async (url, options = {}, idleTimeoutMs = 45000) => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
let timeoutId = null;
|
||||||
|
let idleTimedOut = false;
|
||||||
|
const resetIdleTimer = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
idleTimedOut = true;
|
||||||
|
controller.abort();
|
||||||
|
}, idleTimeoutMs);
|
||||||
|
};
|
||||||
let completedPayload = null;
|
let completedPayload = null;
|
||||||
try {
|
try {
|
||||||
|
resetIdleTimer();
|
||||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(`RAG流式请求失败:HTTP ${response.status}`);
|
const error = new Error(`RAG流式请求失败:HTTP ${response.status}`);
|
||||||
@@ -22964,6 +22973,7 @@ compare: [[flesh, peel]]`;
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
|
if (!done && value && value.length) resetIdleTimer();
|
||||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||||
const frames = buffer.split(/\r?\n\r?\n/);
|
const frames = buffer.split(/\r?\n\r?\n/);
|
||||||
buffer = frames.pop() || '';
|
buffer = frames.pop() || '';
|
||||||
@@ -22978,8 +22988,8 @@ compare: [[flesh, peel]]`;
|
|||||||
}
|
}
|
||||||
return completedPayload;
|
return completedPayload;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.name === 'AbortError') {
|
if (err?.name === 'AbortError' && idleTimedOut) {
|
||||||
const timeoutError = new Error('知识库检索超时(300秒未返回)。');
|
const timeoutError = new Error(`知识库检索连接空闲超时(${Math.round(idleTimeoutMs / 1000)}秒未收到进度)。`);
|
||||||
timeoutError.code = 'request_timeout';
|
timeoutError.code = 'request_timeout';
|
||||||
throw timeoutError;
|
throw timeoutError;
|
||||||
}
|
}
|
||||||
@@ -22989,7 +22999,7 @@ compare: [[flesh, peel]]`;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestRagWithProgress = async (payload, timeoutMs = 300000) => {
|
const requestRagWithProgress = async (payload, idleTimeoutMs = 45000) => {
|
||||||
resetRagProgress();
|
resetRagProgress();
|
||||||
const options = {
|
const options = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -22997,14 +23007,14 @@ compare: [[flesh, peel]]`;
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
return await readRagEventStream('/api/agent/rag/stream', options, timeoutMs);
|
return await readRagEventStream('/api/agent/rag/stream', options, idleTimeoutMs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code !== 'stream_unavailable') throw error;
|
if (error?.code !== 'stream_unavailable') throw error;
|
||||||
applyRagProgressEvent({
|
applyRagProgressEvent({
|
||||||
stage: 'related_search_started',
|
stage: 'related_search_started',
|
||||||
message: '正在知识库中查找相关内容……'
|
message: '正在知识库中查找相关内容……'
|
||||||
});
|
});
|
||||||
return await fetchJsonWithTimeout('/api/agent/rag', options, timeoutMs, '知识库检索');
|
return await fetchJsonWithTimeout('/api/agent/rag', options, 900000, '知识库检索');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23200,15 +23210,11 @@ compare: [[flesh, peel]]`;
|
|||||||
ragHistory.value.push({ role: 'user', content: userInput, time: new Date().toLocaleTimeString() });
|
ragHistory.value.push({ role: 'user', content: userInput, time: new Date().toLocaleTimeString() });
|
||||||
|
|
||||||
// 调用当前 bbav5 后端 RAG 代理,随页面端口走同源地址(例如 7880)
|
// 调用当前 bbav5 后端 RAG 代理,随页面端口走同源地址(例如 7880)
|
||||||
const data = await fetchJsonWithTimeout('/api/agent/rag', {
|
const data = await requestRagWithProgress({
|
||||||
method: 'POST',
|
query: userInput,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
mode: 'rag',
|
||||||
body: JSON.stringify({
|
top_k: 10
|
||||||
query: userInput,
|
});
|
||||||
mode: 'rag',
|
|
||||||
top_k: 10
|
|
||||||
})
|
|
||||||
}, 120000, '知识库检索');
|
|
||||||
const answer = normalizeRagAnswerForDisplay(data.success === false
|
const answer = normalizeRagAnswerForDisplay(data.success === false
|
||||||
? (data.message || '未找到相关信息')
|
? (data.message || '未找到相关信息')
|
||||||
: (data.response || data.answer || '未找到相关信息'), userInput);
|
: (data.response || data.answer || '未找到相关信息'), userInput);
|
||||||
|
|||||||
+22
-16
@@ -22861,11 +22861,20 @@ compare: [[flesh, peel]]`;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const readRagEventStream = async (url, options = {}, timeoutMs = 300000) => {
|
const readRagEventStream = async (url, options = {}, idleTimeoutMs = 45000) => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
let timeoutId = null;
|
||||||
|
let idleTimedOut = false;
|
||||||
|
const resetIdleTimer = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
idleTimedOut = true;
|
||||||
|
controller.abort();
|
||||||
|
}, idleTimeoutMs);
|
||||||
|
};
|
||||||
let completedPayload = null;
|
let completedPayload = null;
|
||||||
try {
|
try {
|
||||||
|
resetIdleTimer();
|
||||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(`RAG流式请求失败:HTTP ${response.status}`);
|
const error = new Error(`RAG流式请求失败:HTTP ${response.status}`);
|
||||||
@@ -22914,6 +22923,7 @@ compare: [[flesh, peel]]`;
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
|
if (!done && value && value.length) resetIdleTimer();
|
||||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||||
const frames = buffer.split(/\r?\n\r?\n/);
|
const frames = buffer.split(/\r?\n\r?\n/);
|
||||||
buffer = frames.pop() || '';
|
buffer = frames.pop() || '';
|
||||||
@@ -22928,8 +22938,8 @@ compare: [[flesh, peel]]`;
|
|||||||
}
|
}
|
||||||
return completedPayload;
|
return completedPayload;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.name === 'AbortError') {
|
if (err?.name === 'AbortError' && idleTimedOut) {
|
||||||
const timeoutError = new Error('知识库检索超时(300秒未返回)。');
|
const timeoutError = new Error(`知识库检索连接空闲超时(${Math.round(idleTimeoutMs / 1000)}秒未收到进度)。`);
|
||||||
timeoutError.code = 'request_timeout';
|
timeoutError.code = 'request_timeout';
|
||||||
throw timeoutError;
|
throw timeoutError;
|
||||||
}
|
}
|
||||||
@@ -22939,7 +22949,7 @@ compare: [[flesh, peel]]`;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestRagWithProgress = async (payload, timeoutMs = 300000) => {
|
const requestRagWithProgress = async (payload, idleTimeoutMs = 45000) => {
|
||||||
resetRagProgress();
|
resetRagProgress();
|
||||||
const options = {
|
const options = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -22947,14 +22957,14 @@ compare: [[flesh, peel]]`;
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
return await readRagEventStream('/api/agent/rag/stream', options, timeoutMs);
|
return await readRagEventStream('/api/agent/rag/stream', options, idleTimeoutMs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code !== 'stream_unavailable') throw error;
|
if (error?.code !== 'stream_unavailable') throw error;
|
||||||
applyRagProgressEvent({
|
applyRagProgressEvent({
|
||||||
stage: 'related_search_started',
|
stage: 'related_search_started',
|
||||||
message: '正在知识库中查找相关内容……'
|
message: '正在知识库中查找相关内容……'
|
||||||
});
|
});
|
||||||
return await fetchJsonWithTimeout('/api/agent/rag', options, timeoutMs, '知识库检索');
|
return await fetchJsonWithTimeout('/api/agent/rag', options, 900000, '知识库检索');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23150,15 +23160,11 @@ compare: [[flesh, peel]]`;
|
|||||||
ragHistory.value.push({ role: 'user', content: userInput, time: new Date().toLocaleTimeString() });
|
ragHistory.value.push({ role: 'user', content: userInput, time: new Date().toLocaleTimeString() });
|
||||||
|
|
||||||
// 调用当前 bbav5 后端 RAG 代理,随页面端口走同源地址
|
// 调用当前 bbav5 后端 RAG 代理,随页面端口走同源地址
|
||||||
const data = await fetchJsonWithTimeout('/api/agent/rag', {
|
const data = await requestRagWithProgress({
|
||||||
method: 'POST',
|
query: userInput,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
mode: 'rag',
|
||||||
body: JSON.stringify({
|
top_k: 10
|
||||||
query: userInput,
|
});
|
||||||
mode: 'rag',
|
|
||||||
top_k: 10
|
|
||||||
})
|
|
||||||
}, 120000, '知识库检索');
|
|
||||||
const answer = normalizeRagAnswerForDisplay(data.success === false
|
const answer = normalizeRagAnswerForDisplay(data.success === false
|
||||||
? (data.message || '未找到相关信息')
|
? (data.message || '未找到相关信息')
|
||||||
: (data.response || data.answer || '未找到相关信息'), userInput);
|
: (data.response || data.answer || '未找到相关信息'), userInput);
|
||||||
|
|||||||
+156
-1
@@ -13490,6 +13490,134 @@ def restart_project(request: Request, payload: AdminOpsRequest):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/validate-qa-sidecar")
|
||||||
|
def validate_qa_sidecar(request: Request, payload: AdminOpsRequest):
|
||||||
|
"""Launch the fixed full-graph QA sidecar validation workflow."""
|
||||||
|
auth = _validate_admin_ops_request(request, payload, "validate-qa-sidecar")
|
||||||
|
script = PROJECT_ROOT / "scripts" / "validate_qa_v1_1_73_sidecar.sh"
|
||||||
|
if not script.is_file():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "not_configured",
|
||||||
|
"message": f"QA sidecar validation script is missing: {script}",
|
||||||
|
"auth": auth,
|
||||||
|
}
|
||||||
|
|
||||||
|
log_path = PROJECT_ROOT / ".runtime" / "qa_v1_1_73" / "sidecar_admin.log"
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
log_handle = open(log_path, "a", encoding="utf-8")
|
||||||
|
subprocess.Popen(
|
||||||
|
["bash", str(script)],
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
stdout=log_handle,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": "validation_submitted",
|
||||||
|
"message": "QA sidecar validation submitted on the fixed loopback port.",
|
||||||
|
"auth": auth,
|
||||||
|
"log": str(log_path),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "submit_failed",
|
||||||
|
"message": str(exc),
|
||||||
|
"auth": auth,
|
||||||
|
"log": str(log_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _qa_sidecar_process_status() -> Dict[str, Any]:
|
||||||
|
runtime_dir = PROJECT_ROOT / ".runtime" / "qa_v1_1_73" / "formal_sidecar_7945"
|
||||||
|
pidfile = runtime_dir / "qa_7945.pid"
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
"pidfile": str(pidfile),
|
||||||
|
"pidfile_exists": pidfile.is_file(),
|
||||||
|
"running": False,
|
||||||
|
"identity_verified": False,
|
||||||
|
}
|
||||||
|
if not pidfile.is_file():
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = int(pidfile.read_text(encoding="utf-8").strip())
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
result["error"] = f"invalid pidfile: {exc}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["pid"] = pid
|
||||||
|
proc_dir = Path("/proc") / str(pid)
|
||||||
|
cmdline_path = proc_dir / "cmdline"
|
||||||
|
if not cmdline_path.is_file():
|
||||||
|
result["status"] = "stale_pidfile"
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmdline = cmdline_path.read_bytes().replace(b"\0", b" ").decode("utf-8", errors="replace").strip()
|
||||||
|
except OSError as exc:
|
||||||
|
result["error"] = f"cannot inspect process: {exc}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
expected_markers = ("uvicorn", "scripts.qa_v1_1_73_server:app", "7945")
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"running": True,
|
||||||
|
"identity_verified": all(marker in cmdline for marker in expected_markers),
|
||||||
|
"command": cmdline,
|
||||||
|
"status": "running",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/qa-sidecar-status")
|
||||||
|
def qa_sidecar_status(request: Request, payload: AdminOpsRequest):
|
||||||
|
"""Inspect only the fixed QA sidecar process on loopback port 7945."""
|
||||||
|
auth = _validate_admin_ops_request(request, payload, "qa-sidecar-status")
|
||||||
|
return {"success": True, "auth": auth, "sidecar": _qa_sidecar_process_status()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/stop-qa-sidecar")
|
||||||
|
def stop_qa_sidecar(request: Request, payload: AdminOpsRequest):
|
||||||
|
"""Stop only the identity-verified QA sidecar on loopback port 7945."""
|
||||||
|
auth = _validate_admin_ops_request(request, payload, "stop-qa-sidecar")
|
||||||
|
before = _qa_sidecar_process_status()
|
||||||
|
if not before.get("running"):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": "already_stopped",
|
||||||
|
"auth": auth,
|
||||||
|
"before": before,
|
||||||
|
}
|
||||||
|
if not before.get("identity_verified"):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "identity_mismatch",
|
||||||
|
"message": "PID exists but is not the fixed QA sidecar; refusing to signal it.",
|
||||||
|
"auth": auth,
|
||||||
|
"before": before,
|
||||||
|
}
|
||||||
|
|
||||||
|
os.kill(int(before["pid"]), signal.SIGTERM)
|
||||||
|
deadline = time.monotonic() + 5.0
|
||||||
|
after = _qa_sidecar_process_status()
|
||||||
|
while after.get("running") and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.1)
|
||||||
|
after = _qa_sidecar_process_status()
|
||||||
|
stopped = not after.get("running")
|
||||||
|
return {
|
||||||
|
"success": stopped,
|
||||||
|
"status": "stopped" if stopped else "stop_submitted",
|
||||||
|
"auth": auth,
|
||||||
|
"before": before,
|
||||||
|
"after": after,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceConfigRequest(BaseModel):
|
class ResourceConfigRequest(BaseModel):
|
||||||
@@ -25181,6 +25309,8 @@ async def rag_query(request: RAGQueryRequest):
|
|||||||
|
|
||||||
基于文献知识库回答问题
|
基于文献知识库回答问题
|
||||||
"""
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from gfa_core.mas.dual_kb_qa import query_dual_kb_qa
|
from gfa_core.mas.dual_kb_qa import query_dual_kb_qa
|
||||||
|
|
||||||
@@ -25206,6 +25336,32 @@ async def rag_query(request: RAGQueryRequest):
|
|||||||
payload["evidence_rag_plan"] = evidence_rag_plan
|
payload["evidence_rag_plan"] = evidence_rag_plan
|
||||||
payload["augmented_query"] = augmented_query
|
payload["augmented_query"] = augmented_query
|
||||||
return payload
|
return payload
|
||||||
|
except requests.Timeout:
|
||||||
|
augmented_query = request.query or ""
|
||||||
|
try:
|
||||||
|
augmented_query, evidence_rag_plan = _augment_rag_query_with_candidate_context(request)
|
||||||
|
_save_candidate_rag_references(
|
||||||
|
request,
|
||||||
|
evidence_rag_plan,
|
||||||
|
augmented_query,
|
||||||
|
sources=[],
|
||||||
|
status="request_timeout",
|
||||||
|
note="complete QA exceeded the configured compatibility timeout",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
evidence_rag_plan = {}
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=504,
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"error_code": "request_timeout",
|
||||||
|
"message": "完整知识图谱问答在配置时限内没有返回。",
|
||||||
|
"answer": "",
|
||||||
|
"sources": [],
|
||||||
|
"mode": "gene_family_mas_qa_timeout",
|
||||||
|
"evidence_rag_plan": evidence_rag_plan,
|
||||||
|
},
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
augmented_query = request.query or ""
|
augmented_query = request.query or ""
|
||||||
@@ -25238,7 +25394,6 @@ async def rag_query_stream(request: RAGQueryRequest):
|
|||||||
for frame in stream_dual_kb_qa(
|
for frame in stream_dual_kb_qa(
|
||||||
augmented_query,
|
augmented_query,
|
||||||
top_k=request.top_k or 10,
|
top_k=request.top_k or 10,
|
||||||
timeout=300.0,
|
|
||||||
):
|
):
|
||||||
if frame.startswith("event: complete"):
|
if frame.startswith("event: complete"):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# 5090 RAG Stream Timeout Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make PlantAI RAG requests on 5090 complete through the full 7920 Qdrant + Neo4j + Agentic pipeline without fixed 120/150-second client aborts.
|
||||||
|
|
||||||
|
**Architecture:** The browser uses the existing SSE endpoint and treats timeout as inactivity rather than total elapsed time. PlantAI proxies 7920 keepalives unchanged, while the Python adapter uses separate connect, stream-idle, and non-stream compatibility timeouts. Retrieval and answer-generation behavior remain unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI, `requests`, browser Fetch/ReadableStream, SSE, pytest.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Preserve complete Qdrant + Neo4j retrieval; no Qdrant-only fallback.
|
||||||
|
- Do not change Agent prompts, rounds, evidence selection, or answer composition.
|
||||||
|
- Stream connect timeout: 10 seconds.
|
||||||
|
- Stream idle timeout: configurable, default 45 seconds.
|
||||||
|
- Non-stream compatibility timeout: configurable, default 900 seconds.
|
||||||
|
- Do not commit or push; the user will handle Git later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Python timeout policy
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `gfa_core/mas/dual_kb_qa.py`
|
||||||
|
- Modify: `tests/test_dual_kb_qa_timeout.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `QA_V1_1_73_TIMEOUT`, new `QA_V1_1_73_STREAM_IDLE_TIMEOUT` environment variables.
|
||||||
|
- Produces: `_non_stream_timeout(timeout: float) -> tuple[float, float]` and `_stream_timeout() -> tuple[float, float]` policies used by both adapter entry points.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the hard-cap assertions with failing policy tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_non_stream_defaults_to_900_second_read_timeout(monkeypatch):
|
||||||
|
_force_v1173(monkeypatch)
|
||||||
|
# capture requests.post timeout
|
||||||
|
assert captured == [(10.0, 900.0)]
|
||||||
|
|
||||||
|
def test_non_stream_honors_timeout_above_150(monkeypatch):
|
||||||
|
monkeypatch.setenv("QA_V1_1_73_TIMEOUT", "600")
|
||||||
|
assert captured == [(10.0, 600.0)]
|
||||||
|
|
||||||
|
def test_stream_uses_45_second_idle_read_timeout(monkeypatch):
|
||||||
|
assert captured == [(10.0, 45.0)]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused tests and verify RED**
|
||||||
|
|
||||||
|
Run: `pytest -q tests/test_dual_kb_qa_timeout.py`
|
||||||
|
|
||||||
|
Expected: failures showing the current 150-second cap.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement separate timeout policies**
|
||||||
|
|
||||||
|
```python
|
||||||
|
QA_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||||
|
QA_V1_1_73_DEFAULT_TIMEOUT_SECONDS = 900.0
|
||||||
|
QA_V1_1_73_DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS = 45.0
|
||||||
|
|
||||||
|
def _positive_timeout(value: object, default: float) -> float:
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return parsed if parsed > 0 else default
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `QA_V1_1_73_TIMEOUT` for non-stream reads and `QA_V1_1_73_STREAM_IDLE_TIMEOUT` for stream idle reads. Remove `min(..., 150)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||||
|
|
||||||
|
Run: `pytest -q tests/test_dual_kb_qa_timeout.py`
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
### Task 2: Browser SSE inactivity timer and RAG route
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/vue3/index.html`
|
||||||
|
- Modify: `apps/web/vue3/index_ml.html`
|
||||||
|
- Create: `tests/unit/test_rag_stream_frontend_contract.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `/api/agent/rag/stream` SSE frames and comment keepalives.
|
||||||
|
- Produces: `readRagEventStream(url, options, idleTimeoutMs)` that resets its timer on every received chunk; RAG mode calls `requestRagWithProgress`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing source-contract tests for both page variants**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.parametrize("name", ["index.html", "index_ml.html"])
|
||||||
|
def test_rag_mode_uses_streaming_request(name):
|
||||||
|
source = (FRONTEND_DIR / name).read_text(encoding="utf-8")
|
||||||
|
rag_block = source[source.index("// V2: RAG模式"):source.index("} catch (err)", source.index("// V2: RAG模式"))]
|
||||||
|
assert "requestRagWithProgress(" in rag_block
|
||||||
|
assert "fetchJsonWithTimeout('/api/agent/rag'" not in rag_block
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["index.html", "index_ml.html"])
|
||||||
|
def test_stream_timer_is_reset_after_each_chunk(name):
|
||||||
|
source = (FRONTEND_DIR / name).read_text(encoding="utf-8")
|
||||||
|
assert "resetIdleTimer();" in source
|
||||||
|
assert "const { value, done } = await reader.read();" in source
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the frontend contract test and verify RED**
|
||||||
|
|
||||||
|
Run: `pytest -q tests/unit/test_rag_stream_frontend_contract.py`
|
||||||
|
|
||||||
|
Expected: both page variants fail because RAG mode uses the non-stream endpoint and the timer is total-duration based.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the inactivity timer in both page variants**
|
||||||
|
|
||||||
|
Define `resetIdleTimer()` to clear and re-arm the abort timer. Call it before fetch and immediately after every successful `reader.read()` that returns a non-empty chunk. Keep `AbortController` for genuine inactivity.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Route both RAG-mode handlers through SSE**
|
||||||
|
|
||||||
|
Replace the direct `fetchJsonWithTimeout('/api/agent/rag', ..., 120000)` call with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const data = await requestRagWithProgress({
|
||||||
|
query: userInput,
|
||||||
|
mode: 'rag',
|
||||||
|
top_k: 10
|
||||||
|
}, 45000);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run frontend contract tests and verify GREEN**
|
||||||
|
|
||||||
|
Run: `pytest -q tests/unit/test_rag_stream_frontend_contract.py`
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
### Task 3: PlantAI API proxy contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/vue3/main.py`
|
||||||
|
- Modify: `tests/test_dual_kb_qa_timeout.py` or create `tests/unit/test_rag_stream_proxy_contract.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `stream_dual_kb_qa(..., idle_timeout=45)`.
|
||||||
|
- Produces: SSE response with unchanged keepalive/progress/complete/error frames.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a failing contract test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
MAIN = ROOT / "apps/web/vue3/main.py"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_proxy_has_no_inline_150_second_timeout():
|
||||||
|
source = MAIN.read_text(encoding="utf-8")
|
||||||
|
start = source.index('@app.post("/api/agent/rag")')
|
||||||
|
end = source.index("# ==================== DiagnosisAgent API", start)
|
||||||
|
rag_routes = source[start:end]
|
||||||
|
assert "timeout=150.0" not in rag_routes
|
||||||
|
assert "complete QA exceeded 150 seconds" not in rag_routes
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the proxy contract test and verify RED**
|
||||||
|
|
||||||
|
Run: `pytest -q tests/unit/test_rag_stream_proxy_contract.py`
|
||||||
|
|
||||||
|
Expected: failure on the current `timeout=150.0` literals.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove inline fixed timeout literals**
|
||||||
|
|
||||||
|
Let `stream_dual_kb_qa` select the 45-second idle timeout and `query_dual_kb_qa` select the 900-second compatibility timeout. Preserve structured timeout and connection error responses.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run proxy and adapter tests and verify GREEN**
|
||||||
|
|
||||||
|
Run: `pytest -q tests/unit/test_rag_stream_proxy_contract.py tests/test_dual_kb_qa_timeout.py`
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
### Task 4: Regression and 5090 deployment validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No production source additions.
|
||||||
|
- Runtime candidate service uses the mounted `sj/PlantAI-dev` tree.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: 5090 port 7920 and candidate PlantAI port 9010.
|
||||||
|
- Produces: health evidence, SSE keepalive evidence, final answer with graph chains/citations.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused and adjacent regression tests**
|
||||||
|
|
||||||
|
Run: `pytest -q tests/test_dual_kb_qa_timeout.py tests/unit/test_rag_stream_frontend_contract.py tests/unit/test_rag_stream_proxy_contract.py`
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate syntax**
|
||||||
|
|
||||||
|
Run: `python -m py_compile gfa_core/mas/dual_kb_qa.py apps/web/vue3/main.py`
|
||||||
|
|
||||||
|
Expected: exit code 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Start/restart only the candidate 9010 service on 5090**
|
||||||
|
|
||||||
|
Synchronize the mounted source into the explicit candidate directory `/home/userlab/plantAI/sj/PlantAI-dev-rag-stream-20260820` on 5090, excluding `.git`, caches, results, and virtual environments. Start `apps/web/vue3/main.py` on port 9010 with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GENE_FAMILY_MAS_API=http://127.0.0.1:7920 \
|
||||||
|
DUAL_KB_QA_ROUTE=qa-v1-1-73 \
|
||||||
|
QA_V1_1_73_TIMEOUT=900 \
|
||||||
|
QA_V1_1_73_STREAM_IDLE_TIMEOUT=45 \
|
||||||
|
python apps/web/vue3/main.py --host 0.0.0.0 --port 9010
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not restart or replace 7920.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify health and SSE keepalive path**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -N --max-time 60 -H 'Content-Type: application/json' \
|
||||||
|
-d '{"query":"What biological processes are regulated by the NAC transcription factor family in plants?","mode":"rag","top_k":10}' \
|
||||||
|
http://127.0.0.1:9010/api/agent/rag/stream
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: an SSE progress frame or `: keepalive` is received before curl reaches 60 seconds; no 504 frame is returned.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify a real long question end to end**
|
||||||
|
|
||||||
|
Run the same curl without a 60-second cap and save the SSE response under `/tmp/plantai-9010-rag-stream-validation.sse`. Success requires an `event: complete` frame whose JSON has a non-empty answer, citations, and `native_graph_chains` or `graph_knowledge_chains`. Record curl's `time_total`; a value greater than 150 seconds is acceptable when earlier progress/keepalive frames are present.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Inspect process and Neo4j state**
|
||||||
|
|
||||||
|
On 5090, check `ps -ef` for only the intended 9010 candidate process and query Neo4j `SHOW TRANSACTIONS` through the existing Neo4j container/CLI. Success requires no transaction from the validation request remaining more than 10 seconds after the complete event.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# QA 7920 Formal Integration Implementation Plan
|
||||||
|
|
||||||
|
**Goal:** Make the validated full-graph QA service and PlantAI timeout behavior versioned in this repository.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Work only in this repository for persistent source changes.
|
||||||
|
- Preserve Qdrant + Neo4j and `QA_V1_BOUNDED_NEO4J_TIMEOUT=8`.
|
||||||
|
- Apply a 10-second connect timeout and 150-second complete-answer timeout.
|
||||||
|
- Validate on a sidecar before replacing 7920.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. Add `tests/test_qa7920_formal_deployment.py` and verify it fails because the formal package and startup entrypoint do not exist.
|
||||||
|
2. Vendor the validated QA package under `services/qa_v1_1_73/src/rag/qa_v1_1_73/`, its three direct support modules, and two YAML configs.
|
||||||
|
3. Add `scripts/qa_v1_1_73_server.py` and `scripts/start_qa_v1_1_73_7920.sh`; make the deployment test pass and compile all Python sources.
|
||||||
|
4. Add `tests/test_dual_kb_qa_timeout.py`; verify it fails against the current 300-second/scalar timeout behavior.
|
||||||
|
5. Change `gfa_core/mas/dual_kb_qa.py` to `timeout=(10.0, 150.0)` and change `apps/web/vue3/main.py` to emit HTTP 504 for non-stream timeout and use 150 seconds for streaming.
|
||||||
|
6. Run focused tests and compilation, then start a sidecar with the existing full backends.
|
||||||
|
7. Require a correct `Solanum lycopersicum` answer, non-empty native graph chains, and wall time below 150 seconds before switching 7920.
|
||||||
|
8. Restart PlantAI through its controlled endpoint and repeat the same assertion through 9010. Roll back runtime processes on any failed assertion.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 5090 RAG 流式超时修订设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让 5090 上的 PlantAI 问答完整使用 7920 的 Qdrant + Neo4j + Agentic QA。复杂问题即使总耗时超过 150 秒,只要 7920 仍持续发送进度或 keepalive,就不能被 PlantAI 客户端误判为超时。此次不减少检索步骤、不跳过辅助问题、不降级为 Qdrant-only。
|
||||||
|
|
||||||
|
## 已确认根因
|
||||||
|
|
||||||
|
- 7920 的 `/api/v1/qa-v1-1-73/chat/stream` 每 15 秒发送 SSE keepalive。
|
||||||
|
- 页面已经定义流式 RAG 客户端,但实际 RAG 模式仍调用旧的 `/api/agent/rag`,并在 120 秒主动中止。
|
||||||
|
- PlantAI 的非流式代理又显式传入 150 秒;`gfa_core/mas/dual_kb_qa.py` 还会将 V1.1.73 的超时限制为最多 150 秒,并忽略更高的环境配置。
|
||||||
|
- 普通 `/chat` 调用在 150 秒时断开后,7920 的同步后台计算仍会继续,造成用户收到超时但服务端任务继续占用资源。
|
||||||
|
- 5090 当前运行 7920 和旧 Web 9017;指定的 `sj/PlantAI-dev` 尚未作为 9010 运行。
|
||||||
|
|
||||||
|
## 设计
|
||||||
|
|
||||||
|
1. 两套页面入口的 RAG 模式都改用已有的 `requestRagWithProgress`,由 PlantAI `/api/agent/rag/stream` 转发 7920 `/chat/stream`。
|
||||||
|
2. 浏览器流读取从“固定总时长计时器”改为“读取空闲计时器”;每次收到 progress、keepalive 或其他数据块就重置计时器。
|
||||||
|
3. 移除 120/150 秒总时长上限。客户端只保留:
|
||||||
|
- 10 秒连接建立超时;
|
||||||
|
- 可配置的读取空闲超时,默认 45 秒。
|
||||||
|
4. 7920 每 15 秒的 keepalive 会重置读取空闲计时,因此正常长请求可以一直运行;如果 7920、网络或代理真正失联,客户端仍会在空闲超时后返回明确错误。
|
||||||
|
5. 非流式兼容接口不再硬编码 150 秒,改用独立的可配置总超时,默认 900 秒。交互式前端不得走该接口。
|
||||||
|
6. 保持完整图谱检索和现有答案生成逻辑,不调整 Agent 提示词、轮次和证据裁剪策略。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- 连接失败返回 `service_unavailable`。
|
||||||
|
- 流在超过空闲超时后无任何字节返回 `request_timeout`。
|
||||||
|
- 收到 keepalive 但总执行超过 150 秒不报错。
|
||||||
|
- 上游返回结构化错误时原样保留错误码和可读信息。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 单元测试证明环境变量高于 150 秒时不再被截断。
|
||||||
|
- 前端契约测试证明 RAG 模式使用流式入口,且每个数据块会重置空闲计时器。
|
||||||
|
- 代理测试模拟持续 keepalive 后最终成功。
|
||||||
|
- 单元测试模拟真正的读取空闲超时并验证 504 错误。
|
||||||
|
- 在 5090 启动指定 `PlantAI-dev` 的候选服务,直连 7920 执行健康检查和真实长问题。
|
||||||
|
- 验证答案仍包含 Neo4j 图链和引用;不以 Qdrant-only 返回作为成功。
|
||||||
|
|
||||||
|
## 回退
|
||||||
|
|
||||||
|
不改动 7920 当前服务。PlantAI 代码修改可通过恢复相关文件或停止候选 9010 服务回退;不 push,由用户后续统一处理 Git。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# QA 7920 正式接入设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
将完成 200 题评估的 QA V1.1.73 源码和启动入口纳入本 Git 仓库,由本机 `127.0.0.1:7920` 为 PlantAI 提供完整 Qdrant + Neo4j 问答,并将整次请求的异常边界设为 150 秒。
|
||||||
|
|
||||||
|
## 设计
|
||||||
|
|
||||||
|
- 将已验证 QA 包放入隔离的 `services/qa_v1_1_73/src/rag/qa_v1_1_73/`,并纳入它直接依赖的 cascaded graph retrieval、relation taxonomy 和 entity alias 模块,避免遮蔽 PlantAI 已使用的外部 `src.rag`。
|
||||||
|
- 配置文件放入 `configs/rag/`;大模型、Qdrant、Neo4j 和论文元数据仍由环境变量指向现有运行资源,不提交数据或密钥。
|
||||||
|
- 正式入口为 `scripts/qa_v1_1_73_server.py`,正式启动脚本为 `scripts/start_qa_v1_1_73_7920.sh`;运行命令和环境中不得出现 `/sandboxes/`。
|
||||||
|
- PlantAI 保持调用同机 `127.0.0.1:7920`。连接超时为 10 秒,完整回答读取超时为 150 秒;超时返回 HTTP 504 或 SSE `request_timeout`。
|
||||||
|
- `QA_V1_BOUNDED_NEO4J_TIMEOUT` 保持 8 秒,不切换 Qdrant-only,不修改图谱或索引。
|
||||||
|
|
||||||
|
## 验证与回退
|
||||||
|
|
||||||
|
先用契约测试证明仓库缺少正式 QA 服务,再加入源码使测试通过。在旁路端口验证健康信息、完整后端身份和历史测试题;只有在 150 秒内正确返回且图谱链非空时才替换 7920。失败时恢复旧 PID,不切换 9010。
|
||||||
+45
-12
@@ -22,6 +22,9 @@ DUAL_KB_QA_BASE_URL = (
|
|||||||
DUAL_KB_QA_ROUTE = os.environ.get("DUAL_KB_QA_ROUTE", "kb-qa").strip().strip("/") or "kb-qa"
|
DUAL_KB_QA_ROUTE = os.environ.get("DUAL_KB_QA_ROUTE", "kb-qa").strip().strip("/") or "kb-qa"
|
||||||
DUAL_KB_GRAPH_QA_ROUTE = os.environ.get("DUAL_KB_GRAPH_QA_ROUTE", "v15-graph-qa").strip().strip("/") or "v15-graph-qa"
|
DUAL_KB_GRAPH_QA_ROUTE = os.environ.get("DUAL_KB_GRAPH_QA_ROUTE", "v15-graph-qa").strip().strip("/") or "v15-graph-qa"
|
||||||
DUAL_KB_QA_VARIANT = os.environ.get("DUAL_KB_QA_VARIANT", "full").strip() or "full"
|
DUAL_KB_QA_VARIANT = os.environ.get("DUAL_KB_QA_VARIANT", "full").strip() or "full"
|
||||||
|
QA_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||||
|
QA_V1_1_73_DEFAULT_TIMEOUT_SECONDS = 900.0
|
||||||
|
QA_V1_1_73_DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS = 45.0
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
@@ -31,6 +34,35 @@ def _env_bool(name: str, default: bool) -> bool:
|
|||||||
return value.strip().lower() not in {"0", "false", "no", "off"}
|
return value.strip().lower() not in {"0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_timeout(value: object, default: float) -> float:
|
||||||
|
try:
|
||||||
|
parsed = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return parsed if parsed > 0 else default
|
||||||
|
|
||||||
|
|
||||||
|
def _non_stream_timeout(timeout: float) -> tuple[float, float]:
|
||||||
|
read_timeout = _positive_timeout(
|
||||||
|
os.environ.get("QA_V1_1_73_TIMEOUT", timeout),
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
return QA_CONNECT_TIMEOUT_SECONDS, read_timeout
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_timeout(timeout: float | None = None) -> tuple[float, float]:
|
||||||
|
default = (
|
||||||
|
_positive_timeout(timeout, QA_V1_1_73_DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS)
|
||||||
|
if timeout is not None
|
||||||
|
else QA_V1_1_73_DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
idle_timeout = _positive_timeout(
|
||||||
|
os.environ.get("QA_V1_1_73_STREAM_IDLE_TIMEOUT", default),
|
||||||
|
default,
|
||||||
|
)
|
||||||
|
return QA_CONNECT_TIMEOUT_SECONDS, idle_timeout
|
||||||
|
|
||||||
|
|
||||||
def dual_kb_endpoint(path: str = "chat") -> str:
|
def dual_kb_endpoint(path: str = "chat") -> str:
|
||||||
return f"{DUAL_KB_QA_BASE_URL}/api/v1/{DUAL_KB_QA_ROUTE}/{path.strip('/')}"
|
return f"{DUAL_KB_QA_BASE_URL}/api/v1/{DUAL_KB_QA_ROUTE}/{path.strip('/')}"
|
||||||
|
|
||||||
@@ -221,6 +253,7 @@ def _build_dual_kb_payload(
|
|||||||
if route == "qa-v1-1-73":
|
if route == "qa-v1-1-73":
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"question": question,
|
"question": question,
|
||||||
|
"response_detail": "answer",
|
||||||
"debug": False,
|
"debug": False,
|
||||||
"use_llm": resolved_use_llm,
|
"use_llm": resolved_use_llm,
|
||||||
"chain_top_k": 3,
|
"chain_top_k": 3,
|
||||||
@@ -315,7 +348,7 @@ def query_dual_kb_qa(
|
|||||||
graph_hops: int = 2,
|
graph_hops: int = 2,
|
||||||
graph_limit_nodes: int = 60,
|
graph_limit_nodes: int = 60,
|
||||||
graph_limit_edges: int = 100,
|
graph_limit_edges: int = 100,
|
||||||
timeout: float = 240.0,
|
timeout: float = QA_V1_1_73_DEFAULT_TIMEOUT_SECONDS,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
question = str(question or "").strip()
|
question = str(question or "").strip()
|
||||||
if not question:
|
if not question:
|
||||||
@@ -338,15 +371,15 @@ def query_dual_kb_qa(
|
|||||||
effective_use_llm = bool(payload.get("use_llm"))
|
effective_use_llm = bool(payload.get("use_llm"))
|
||||||
request_path = "chat" if effective_use_llm else "search"
|
request_path = "chat" if effective_use_llm else "search"
|
||||||
|
|
||||||
request_timeout = (
|
transport_timeout = (
|
||||||
max(timeout, float(os.environ.get("QA_V1_1_73_TIMEOUT", "300")))
|
_non_stream_timeout(timeout)
|
||||||
if route == "qa-v1-1-73"
|
if route == "qa-v1-1-73"
|
||||||
else timeout
|
else float(timeout)
|
||||||
)
|
)
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
dual_kb_endpoint(request_path),
|
dual_kb_endpoint(request_path),
|
||||||
json=payload,
|
json=payload,
|
||||||
timeout=request_timeout,
|
timeout=transport_timeout,
|
||||||
)
|
)
|
||||||
data = response.json() if response.content else {}
|
data = response.json() if response.content else {}
|
||||||
initial_detail = data.get("detail") if isinstance(data, dict) else None
|
initial_detail = data.get("detail") if isinstance(data, dict) else None
|
||||||
@@ -359,7 +392,7 @@ def query_dual_kb_qa(
|
|||||||
response = requests.post(
|
response = requests.post(
|
||||||
dual_kb_endpoint(request_path),
|
dual_kb_endpoint(request_path),
|
||||||
json=payload,
|
json=payload,
|
||||||
timeout=request_timeout,
|
timeout=transport_timeout,
|
||||||
)
|
)
|
||||||
data = response.json() if response.content else {}
|
data = response.json() if response.content else {}
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
@@ -401,7 +434,7 @@ def stream_dual_kb_qa(
|
|||||||
graph_hops: int = 2,
|
graph_hops: int = 2,
|
||||||
graph_limit_nodes: int = 60,
|
graph_limit_nodes: int = 60,
|
||||||
graph_limit_edges: int = 100,
|
graph_limit_edges: int = 100,
|
||||||
timeout: float = 300.0,
|
timeout: float | None = None,
|
||||||
) -> Iterator[str]:
|
) -> Iterator[str]:
|
||||||
"""Proxy truthful QA milestones and normalize the final V1.1.73 payload."""
|
"""Proxy truthful QA milestones and normalize the final V1.1.73 payload."""
|
||||||
question = str(question or "").strip()
|
question = str(question or "").strip()
|
||||||
@@ -426,17 +459,17 @@ def stream_dual_kb_qa(
|
|||||||
graph_limit_nodes=graph_limit_nodes,
|
graph_limit_nodes=graph_limit_nodes,
|
||||||
graph_limit_edges=graph_limit_edges,
|
graph_limit_edges=graph_limit_edges,
|
||||||
)
|
)
|
||||||
request_timeout = (
|
transport_timeout = (
|
||||||
max(timeout, float(os.environ.get("QA_V1_1_73_TIMEOUT", "300")))
|
_stream_timeout(timeout)
|
||||||
if route == "qa-v1-1-73"
|
if route == "qa-v1-1-73"
|
||||||
else timeout
|
else _positive_timeout(timeout, QA_V1_1_73_DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
dual_kb_endpoint("chat/stream"),
|
dual_kb_endpoint("chat/stream"),
|
||||||
json=payload,
|
json=payload,
|
||||||
stream=True,
|
stream=True,
|
||||||
timeout=(10.0, request_timeout),
|
timeout=transport_timeout,
|
||||||
)
|
)
|
||||||
except requests.Timeout as exc:
|
except requests.Timeout as exc:
|
||||||
yield _sse_frame("error", {
|
yield _sse_frame("error", {
|
||||||
@@ -475,7 +508,7 @@ def stream_dual_kb_qa(
|
|||||||
|
|
||||||
event_name = "message"
|
event_name = "message"
|
||||||
data_lines: list[str] = []
|
data_lines: list[str] = []
|
||||||
for raw_line in response.iter_lines(decode_unicode=True):
|
for raw_line in response.iter_lines(chunk_size=1, decode_unicode=True):
|
||||||
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else str(raw_line)
|
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else str(raw_line)
|
||||||
if line.startswith(":"):
|
if line.startswith(":"):
|
||||||
yield ": keepalive\n\n"
|
yield ": keepalive\n\n"
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
QA_SERVICE_ROOT = PROJECT_ROOT / "services" / "qa_v1_1_73"
|
||||||
|
if str(QA_SERVICE_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(QA_SERVICE_ROOT))
|
||||||
|
|
||||||
|
from src.rag.qa_v1_1_73 import __version__
|
||||||
|
from src.rag.qa_v1_1_73.api import router
|
||||||
|
from src.rag.qa_v1_1_73.backend_guard import validate_backends
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Plant Biology QA V1.1.73",
|
||||||
|
version=__version__,
|
||||||
|
description=(
|
||||||
|
"Production full-graph QA with assertion-anchored Qdrant recall, "
|
||||||
|
"bounded canonical Neo4j expansion, BGE reranking, and citation validation."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def validate_production_backends() -> None:
|
||||||
|
app.state.kb_backend_identity = validate_backends()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "qa_v1_1_73",
|
||||||
|
"version": __version__,
|
||||||
|
"source": "plantai_git_repository",
|
||||||
|
"backend": app.state.kb_backend_identity,
|
||||||
|
}
|
||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
QA_SERVICE_ROOT="$PROJECT_ROOT/services/qa_v1_1_73"
|
||||||
|
APP_MODULE="scripts.qa_v1_1_73_server:app"
|
||||||
|
RUNTIME_ENV="${QA_V1_RUNTIME_ENV:-/home/xzq/5090/PlanAI-cooperate/RAG/runtime/qa_v1_1_73.env}"
|
||||||
|
PYTHON="${QA_V1_PYTHON:-/home/xzq/miniconda3/envs/gfpy312/bin/python}"
|
||||||
|
PORT="${QA_V1_1_73_PORT:-7920}"
|
||||||
|
RUNTIME_DIR="${QA_V1_RUNTIME_DIR:-$PROJECT_ROOT/.runtime/qa_v1_1_73}"
|
||||||
|
LOG="$RUNTIME_DIR/qa_$PORT.log"
|
||||||
|
PIDFILE="$RUNTIME_DIR/qa_$PORT.pid"
|
||||||
|
|
||||||
|
mkdir -p "$RUNTIME_DIR"
|
||||||
|
|
||||||
|
if [[ ! -x "$PYTHON" ]]; then
|
||||||
|
echo "ERROR: Python interpreter is missing: $PYTHON" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -r "$RUNTIME_ENV" ]]; then
|
||||||
|
echo "ERROR: Runtime environment is missing: $RUNTIME_ENV" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$QA_SERVICE_ROOT/src/rag/qa_v1_1_73/api.py" ]]; then
|
||||||
|
echo "ERROR: Versioned QA source is missing from $QA_SERVICE_ROOT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ss -ltn | awk '{print $4}' | grep -qE ":$PORT$"; then
|
||||||
|
echo "ERROR: Port $PORT is already in use." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
source "$RUNTIME_ENV"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
export GENE_FAMILY_ROOT="$QA_SERVICE_ROOT"
|
||||||
|
export PYTHONPATH="$QA_SERVICE_ROOT:$PROJECT_ROOT"
|
||||||
|
export NEO4J_URI="bolt://127.0.0.1:18788"
|
||||||
|
export NEO4J_USER="${NEO4J_USER:-neo4j}"
|
||||||
|
export QA_V1_GRAPH_SCHEMA="canonical"
|
||||||
|
export V15_QDRANT_URL="http://127.0.0.1:26333"
|
||||||
|
export V15_QDRANT_COLLECTION="plant_srt_direct_v1_bge_m3_20260702"
|
||||||
|
export QA_V1_EMBED_URL="http://127.0.0.1:11434/api/embed"
|
||||||
|
export QA_V1_EMBED_MODEL="bge-m3:567m"
|
||||||
|
export QA_V1_BGE_RERANKER_MODEL="/home/xzq/5090/PlantAI-rag/bge-reranker-base"
|
||||||
|
export QA_V1_BGE_DEVICE="cuda"
|
||||||
|
export QA_PAPER_METADATA_REGISTRY="/home/xzq/5090/PlantAI-dev4090-7/gene-family-mas-v2_qa_v1_1_73_current/data/knowledge_base/paper_metadata_registry_v1_20260717/paper_metadata_registry_full.jsonl"
|
||||||
|
export QA_V1_ENABLE_PROVISIONAL_DRAFT="0"
|
||||||
|
export QA_V1_MAX_ROUNDS="1"
|
||||||
|
export QA_V1_ENABLE_AGENTIC_CONTROLLER="1"
|
||||||
|
export QA_V1_AGENTIC_MAX_REPLANS="1"
|
||||||
|
export QA_V1_ALLOW_AGENTIC_REVIEW_FALLBACK="1"
|
||||||
|
export QA_V1_ENABLE_AGENTIC_HOP_GATE="1"
|
||||||
|
export QA_V1_AGENTIC_HOP_GATE_MAX_HOPS="3"
|
||||||
|
export QA_ENABLE_QUESTION_DECOMPOSITION="1"
|
||||||
|
export QA_V1_SUBQUESTION_WORKERS="3"
|
||||||
|
export QA_V1_VECTOR_QUERY_WORKERS="2"
|
||||||
|
export QA_V1_BOUNDED_MAX_FRONTIER="24"
|
||||||
|
export QA_V1_BOUNDED_PER_NODE="12"
|
||||||
|
export QA_V1_BOUNDED_MAX_HOPS="3"
|
||||||
|
export QA_V1_BOUNDED_NEO4J_TIMEOUT="8"
|
||||||
|
export QA_V1_BOUNDED_QDRANT_TIMEOUT="30"
|
||||||
|
export QA_V1_BOUNDED_EMBED_TIMEOUT="30"
|
||||||
|
export LD_LIBRARY_PATH="/home/xzq/miniconda3/envs/gfpy312/lib:${LD_LIBRARY_PATH-}"
|
||||||
|
|
||||||
|
nohup "$PYTHON" -m uvicorn "$APP_MODULE" \
|
||||||
|
--app-dir "$PROJECT_ROOT" \
|
||||||
|
--host 127.0.0.1 \
|
||||||
|
--port "$PORT" > "$LOG" 2>&1 < /dev/null &
|
||||||
|
echo $! > "$PIDFILE"
|
||||||
|
|
||||||
|
for _ in $(seq 1 90); do
|
||||||
|
if curl --max-time 2 -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||||
|
echo "QA service started on http://127.0.0.1:$PORT"
|
||||||
|
echo "PID file: $PIDFILE"
|
||||||
|
echo "Log: $LOG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "ERROR: QA service failed to become healthy. See $LOG" >&2
|
||||||
|
exit 1
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SIDECAR_PORT="${QA_V1_SIDECAR_PORT:-7945}"
|
||||||
|
EVIDENCE_DIR="$PROJECT_ROOT/.runtime/qa_v1_1_73/formal_sidecar_$SIDECAR_PORT"
|
||||||
|
QUESTION="In which species was tissue-specific expression of CAMTA3 (SR1) examined?"
|
||||||
|
|
||||||
|
mkdir -p "$EVIDENCE_DIR"
|
||||||
|
|
||||||
|
QA_V1_1_73_PORT="$SIDECAR_PORT" \
|
||||||
|
QA_V1_RUNTIME_DIR="$EVIDENCE_DIR" \
|
||||||
|
bash "$PROJECT_ROOT/scripts/start_qa_v1_1_73_7920.sh" \
|
||||||
|
> "$EVIDENCE_DIR/launch.txt" 2>&1
|
||||||
|
|
||||||
|
curl -fsS --max-time 15 \
|
||||||
|
"http://127.0.0.1:$SIDECAR_PORT/health" \
|
||||||
|
> "$EVIDENCE_DIR/health.json"
|
||||||
|
curl -fsS --max-time 15 \
|
||||||
|
"http://127.0.0.1:$SIDECAR_PORT/api/v1/qa-v1-1-73/info" \
|
||||||
|
> "$EVIDENCE_DIR/info.json"
|
||||||
|
curl -sS --max-time 150 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"question\":\"$QUESTION\",\"debug\":false,\"use_llm\":true,\"chain_top_k\":3,\"chain_hops\":2,\"chain_max_edges\":12,\"chain_max_nodes\":10,\"native_chain_enabled\":true,\"native_chain_max_hops\":3,\"native_chain_top_k\":5}" \
|
||||||
|
-o "$EVIDENCE_DIR/response.json" \
|
||||||
|
-w "status=%{http_code} total=%{time_total}s bytes=%{size_download}\\n" \
|
||||||
|
"http://127.0.0.1:$SIDECAR_PORT/api/v1/qa-v1-1-73/chat" \
|
||||||
|
> "$EVIDENCE_DIR/timing.txt"
|
||||||
|
|
||||||
|
"${QA_V1_PYTHON:-/home/xzq/miniconda3/envs/gfpy312/bin/python}" - "$EVIDENCE_DIR" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
evidence_dir = Path(sys.argv[1])
|
||||||
|
health = json.loads((evidence_dir / "health.json").read_text(encoding="utf-8"))
|
||||||
|
info = json.loads((evidence_dir / "info.json").read_text(encoding="utf-8"))
|
||||||
|
response = json.loads((evidence_dir / "response.json").read_text(encoding="utf-8"))
|
||||||
|
timing = (evidence_dir / "timing.txt").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert health["status"] == "ok"
|
||||||
|
assert health["source"] == "plantai_git_repository"
|
||||||
|
assert "sandbox" not in json.dumps(health).lower()
|
||||||
|
assert "sandbox" not in json.dumps(info).lower()
|
||||||
|
assert info["config"]["graph_schema"] == "canonical"
|
||||||
|
assert "status=200" in timing
|
||||||
|
assert response.get("success", True) is True
|
||||||
|
assert "Solanum lycopersicum" in response.get("answer", "")
|
||||||
|
assert response.get("native_graph_chains")
|
||||||
|
|
||||||
|
(evidence_dir / "PASS").write_text("sidecar validation passed\n", encoding="utf-8")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
Arabidopsis thaliana:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Arabidopsis thaliana
|
||||||
|
- A. thaliana
|
||||||
|
- Arabidopsis
|
||||||
|
- 拟南芥
|
||||||
|
forbidden_expansions:
|
||||||
|
- Brassicaceae
|
||||||
|
- cruciferous plants
|
||||||
|
- 十字花科
|
||||||
|
|
||||||
|
Oryza sativa:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Oryza sativa
|
||||||
|
- O. sativa
|
||||||
|
- rice
|
||||||
|
- 水稻
|
||||||
|
|
||||||
|
Glycine max:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Glycine max
|
||||||
|
- G. max
|
||||||
|
- soybean
|
||||||
|
- soybean plant
|
||||||
|
- 大豆
|
||||||
|
|
||||||
|
Zea mays:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Zea mays
|
||||||
|
- Z. mays
|
||||||
|
- maize
|
||||||
|
- corn
|
||||||
|
- 玉米
|
||||||
|
|
||||||
|
Litchi chinensis:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Litchi chinensis
|
||||||
|
- L. chinensis
|
||||||
|
- litchi
|
||||||
|
- lychee
|
||||||
|
- 荔枝
|
||||||
|
|
||||||
|
Triticum aestivum:
|
||||||
|
type: species
|
||||||
|
retrieval_label: wheat
|
||||||
|
aliases:
|
||||||
|
- Triticum aestivum
|
||||||
|
- T. aestivum
|
||||||
|
- wheat
|
||||||
|
- bread wheat
|
||||||
|
- common wheat
|
||||||
|
- 小麦
|
||||||
|
|
||||||
|
Solanum lycopersicum:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Solanum lycopersicum
|
||||||
|
- S. lycopersicum
|
||||||
|
- tomato
|
||||||
|
- 番茄
|
||||||
|
|
||||||
|
Solanum melongena:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Solanum melongena
|
||||||
|
- S. melongena
|
||||||
|
- eggplant
|
||||||
|
- brinjal
|
||||||
|
- 茄子
|
||||||
|
|
||||||
|
Solanum tuberosum:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Solanum tuberosum
|
||||||
|
- S. tuberosum
|
||||||
|
- potato
|
||||||
|
- 马铃薯
|
||||||
|
- 土豆
|
||||||
|
|
||||||
|
Ipomoea batatas:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Ipomoea batatas
|
||||||
|
- I. batatas
|
||||||
|
- sweet potato
|
||||||
|
- sweetpotato
|
||||||
|
- 甘薯
|
||||||
|
- 红薯
|
||||||
|
forbidden_expansions:
|
||||||
|
- Solanum tuberosum
|
||||||
|
- S. tuberosum
|
||||||
|
- potato
|
||||||
|
- 马铃薯
|
||||||
|
- 土豆
|
||||||
|
|
||||||
|
GmP450 genes:
|
||||||
|
type: gene_family
|
||||||
|
aliases:
|
||||||
|
- GmP450 genes
|
||||||
|
- GmP450 gene family
|
||||||
|
- GmP450
|
||||||
|
- soybean P450 genes
|
||||||
|
- soybean cytochrome P450 genes
|
||||||
|
|
||||||
|
SmCYP450 genes:
|
||||||
|
type: gene_family
|
||||||
|
aliases:
|
||||||
|
- SmCYP450 genes
|
||||||
|
- SmCYP450 gene family
|
||||||
|
- SmCYP450
|
||||||
|
- eggplant CYP450 genes
|
||||||
|
- eggplant cytochrome P450 genes
|
||||||
|
|
||||||
|
SPL gene family:
|
||||||
|
type: gene_family
|
||||||
|
retrieval_label: SPL genes
|
||||||
|
aliases:
|
||||||
|
- SPL gene family
|
||||||
|
- SPL 基因家族
|
||||||
|
- SPL genes
|
||||||
|
- SPLs
|
||||||
|
- SPL
|
||||||
|
- SBP genes
|
||||||
|
- SBP-box genes
|
||||||
|
- Squamosa promoter-binding protein-like genes
|
||||||
|
- SQUAMOSA promoter binding protein-like genes
|
||||||
|
|
||||||
|
flowering:
|
||||||
|
type: biological_process
|
||||||
|
retrieval_terms:
|
||||||
|
- ovary
|
||||||
|
- anthesis
|
||||||
|
- expression
|
||||||
|
aliases:
|
||||||
|
- flowering
|
||||||
|
- flowering time
|
||||||
|
- floral transition
|
||||||
|
- 开花
|
||||||
|
|
||||||
|
GST genes:
|
||||||
|
type: gene_family
|
||||||
|
aliases:
|
||||||
|
- GST genes
|
||||||
|
- GST gene family
|
||||||
|
- GSTs
|
||||||
|
- GST
|
||||||
|
- glutathione S-transferase genes
|
||||||
|
- glutathione S-transferase gene family
|
||||||
|
- glutathione S-transferases
|
||||||
|
- glutathione S-transferase
|
||||||
|
- 谷胱甘肽S-转移酶
|
||||||
|
- 谷胱甘肽 S-转移酶
|
||||||
|
- 谷胱甘肽S-转移酶基因
|
||||||
|
- 谷胱甘肽S-转移酶基因家族
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
expression:
|
||||||
|
location:
|
||||||
|
- expressed_in
|
||||||
|
- highly_expressed_in
|
||||||
|
- predominantly_expressed_in
|
||||||
|
- accumulated_at_higher_level_in
|
||||||
|
- detected_in
|
||||||
|
- enriched_in
|
||||||
|
up:
|
||||||
|
- upregulated_in
|
||||||
|
- induced_under
|
||||||
|
- induced_in
|
||||||
|
- increased_in
|
||||||
|
- increased_under
|
||||||
|
- enhanced_expression_in
|
||||||
|
- accumulated_at_higher_level_in
|
||||||
|
down:
|
||||||
|
- downregulated_in
|
||||||
|
- decreased_in
|
||||||
|
- decreased_under
|
||||||
|
- reduced_in
|
||||||
|
- repressed_in
|
||||||
|
condition:
|
||||||
|
- differentially_expressed_under
|
||||||
|
- expression_changed_under
|
||||||
|
- expression_changed_in
|
||||||
|
- responds_to
|
||||||
|
- responsive_to
|
||||||
|
- not_induced_under
|
||||||
|
|
||||||
|
catalog_count:
|
||||||
|
- has_member_count
|
||||||
|
- identified_in
|
||||||
|
- systematically_identified_in
|
||||||
|
- genome_wide_identified_in
|
||||||
|
- were_identified_in
|
||||||
|
- was_identified_in
|
||||||
|
- identified
|
||||||
|
- were identified in
|
||||||
|
- was identified in
|
||||||
|
- identified in
|
||||||
|
- contains
|
||||||
|
- contain
|
||||||
|
- encodes
|
||||||
|
- include
|
||||||
|
- includes
|
||||||
|
- had
|
||||||
|
- has_number_of_members
|
||||||
|
- comprehensively_analyzed_in
|
||||||
|
- analyzed_in
|
||||||
|
- surveyed_in
|
||||||
|
|
||||||
|
classification:
|
||||||
|
- belongs_to
|
||||||
|
- member_of
|
||||||
|
- classified_into
|
||||||
|
- divided_into
|
||||||
|
- separated_into
|
||||||
|
- grouped_into
|
||||||
|
- clustered_with
|
||||||
|
|
||||||
|
localization:
|
||||||
|
- localizes_to
|
||||||
|
- located_in
|
||||||
|
- predicted_to_localize_to
|
||||||
|
- subcellular_localized_to
|
||||||
|
|
||||||
|
structure:
|
||||||
|
- contains_domain
|
||||||
|
- contains_motif
|
||||||
|
- contains_cis_element
|
||||||
|
- has_intron_count
|
||||||
|
- has_exon_count
|
||||||
|
- has_conserved_domain
|
||||||
|
- lacks_binding_site_for
|
||||||
|
|
||||||
|
function_effect:
|
||||||
|
- promotes
|
||||||
|
- inhibits
|
||||||
|
- enhances
|
||||||
|
- reduces
|
||||||
|
- affects
|
||||||
|
- regulates
|
||||||
|
- involved_in
|
||||||
|
- participates_in
|
||||||
|
- contributes_to
|
||||||
|
- associated_with
|
||||||
|
|
||||||
|
perturbation_effect:
|
||||||
|
- overexpression_increases
|
||||||
|
- overexpression_enhances
|
||||||
|
- overexpression_promotes
|
||||||
|
- overexpression_causes
|
||||||
|
- knockdown_reduces
|
||||||
|
- knockdown_enhances
|
||||||
|
- silencing_reduces
|
||||||
|
- mutation_affects
|
||||||
|
|
||||||
|
method:
|
||||||
|
- used_tool
|
||||||
|
- analyzed_by
|
||||||
|
- predicted_by
|
||||||
|
- aligned_with
|
||||||
|
- constructed_with
|
||||||
|
- identified_by
|
||||||
|
|
||||||
|
dataset:
|
||||||
|
- has_accession
|
||||||
|
- available_at
|
||||||
|
- has_data_type
|
||||||
|
- from_database
|
||||||
|
- from_article
|
||||||
|
- used_for
|
||||||
|
|
||||||
|
prior_work:
|
||||||
|
- reported_in
|
||||||
|
- previously_reported_in
|
||||||
|
- cited_as
|
||||||
|
- known_to
|
||||||
|
|
||||||
|
stress_response:
|
||||||
|
- responds_to
|
||||||
|
- responsive_to
|
||||||
|
- induced_by
|
||||||
|
- induced_under
|
||||||
|
- induced_in
|
||||||
|
- upregulated_under
|
||||||
|
- upregulated_in
|
||||||
|
- involved_in_stress_response
|
||||||
|
- associated_with_stress_tolerance
|
||||||
|
- confers_tolerance_to
|
||||||
|
- enhances_tolerance_to
|
||||||
|
- improves_tolerance_to
|
||||||
|
- stress tolerance
|
||||||
|
- abiotic stress
|
||||||
|
- biotic stress
|
||||||
|
|
||||||
|
detoxification:
|
||||||
|
- involved_in_detoxification
|
||||||
|
- detoxifies
|
||||||
|
- detoxification
|
||||||
|
- conjugates_with_glutathione
|
||||||
|
- catalyzes_glutathione_conjugation
|
||||||
|
- scavenges
|
||||||
|
- removes
|
||||||
|
- clears
|
||||||
|
- reduces_ros
|
||||||
|
- maintains_redox_homeostasis
|
||||||
|
- glutathione conjugation
|
||||||
|
- xenobiotic detoxification
|
||||||
|
|
||||||
|
oxidative_stress:
|
||||||
|
- scavenges_ros
|
||||||
|
- scavenges_h2o2
|
||||||
|
- reduces_h2o2
|
||||||
|
- regulates_redox_homeostasis
|
||||||
|
- enhances_oxidative_stress_tolerance
|
||||||
|
- involved_in_antioxidant_response
|
||||||
|
- reactive oxygen species
|
||||||
|
- oxidative stress
|
||||||
|
- redox homeostasis
|
||||||
|
|
||||||
|
functional_validation:
|
||||||
|
- overexpression_enhances
|
||||||
|
- overexpression_increases
|
||||||
|
- overexpression_confers
|
||||||
|
- overexpression_improves
|
||||||
|
- overexpression_promotes
|
||||||
|
- over-expression
|
||||||
|
- overexpression
|
||||||
|
- co_expression_enhances
|
||||||
|
- co-expression
|
||||||
|
- transgenic
|
||||||
|
- mutation_affects
|
||||||
|
- knockdown_reduces
|
||||||
|
- silencing_reduces
|
||||||
|
|
||||||
|
family_expansion:
|
||||||
|
- expanded_by
|
||||||
|
- tandemly_duplicated
|
||||||
|
- segmentally_duplicated
|
||||||
|
- retained_after_wgd
|
||||||
|
- underwent_positive_selection
|
||||||
|
- functionally_diverged
|
||||||
|
- supergene family
|
||||||
|
- gene family expansion
|
||||||
|
- tandem duplication
|
||||||
|
- segmental duplication
|
||||||
|
- positive selection
|
||||||
|
|
||||||
|
signaling_regulation:
|
||||||
|
- upregulated_by
|
||||||
|
- activated_by
|
||||||
|
- regulated_by
|
||||||
|
- downstream_of
|
||||||
|
- target_of
|
||||||
|
- ABA signaling
|
||||||
|
- signal transduction
|
||||||
|
- kinase
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Versioned source package for the PlantAI full-graph QA service."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""RAG modules vendored for the PlantAI full-graph QA service."""
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Dual-KB taxonomy-aware retrieval helpers."""
|
||||||
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class EntityAliasNormalizer:
|
||||||
|
def __init__(self, path: str | Path | None = None):
|
||||||
|
self.path = Path(path) if path else None
|
||||||
|
self.entries: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self.alias_to_canonical: Dict[str, str] = {}
|
||||||
|
if self.path and self.path.exists():
|
||||||
|
self._load(self.path)
|
||||||
|
|
||||||
|
def _load(self, path: Path) -> None:
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
for canonical, payload in data.items():
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {"aliases": payload if isinstance(payload, list) else []}
|
||||||
|
aliases = [str(x) for x in payload.get("aliases", []) if str(x).strip()]
|
||||||
|
aliases.append(str(canonical))
|
||||||
|
entry = {
|
||||||
|
"canonical": str(canonical),
|
||||||
|
"type": str(payload.get("type", "")),
|
||||||
|
"aliases": sorted(set(aliases), key=len, reverse=True),
|
||||||
|
"forbidden_expansions": [str(x) for x in payload.get("forbidden_expansions", [])],
|
||||||
|
}
|
||||||
|
self.entries[str(canonical)] = entry
|
||||||
|
for alias in entry["aliases"]:
|
||||||
|
self.alias_to_canonical[self._norm(alias)] = str(canonical)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _norm(text: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", str(text).strip().lower())
|
||||||
|
|
||||||
|
def normalize_mentions(self, question: str, mentions: List[str]) -> Dict[str, Any]:
|
||||||
|
normalized: List[Dict[str, Any]] = []
|
||||||
|
seen = set()
|
||||||
|
hay = self._norm(question)
|
||||||
|
for mention in mentions:
|
||||||
|
m_norm = self._norm(mention)
|
||||||
|
canonical = self.alias_to_canonical.get(m_norm, mention)
|
||||||
|
key = self._norm(canonical)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
entry = self.entries.get(canonical, {})
|
||||||
|
normalized.append({
|
||||||
|
"mention": mention,
|
||||||
|
"canonical": canonical,
|
||||||
|
"type": entry.get("type", ""),
|
||||||
|
"matched_alias": m_norm in self.alias_to_canonical,
|
||||||
|
"forbidden_expansions": entry.get("forbidden_expansions", []),
|
||||||
|
})
|
||||||
|
# Also detect aliases directly in the question.
|
||||||
|
for alias_norm, canonical in self.alias_to_canonical.items():
|
||||||
|
if alias_norm in hay and self._norm(canonical) not in seen:
|
||||||
|
seen.add(self._norm(canonical))
|
||||||
|
entry = self.entries.get(canonical, {})
|
||||||
|
normalized.append({
|
||||||
|
"mention": alias_norm,
|
||||||
|
"canonical": canonical,
|
||||||
|
"type": entry.get("type", ""),
|
||||||
|
"matched_alias": True,
|
||||||
|
"forbidden_expansions": entry.get("forbidden_expansions", []),
|
||||||
|
})
|
||||||
|
return {"entities": normalized, "config_path": str(self.path or "")}
|
||||||
|
|
||||||
|
def forbidden_hits(self, question: str, row_text: str, normalized_entities: List[Dict[str, Any]]) -> List[str]:
|
||||||
|
q = self._norm(question)
|
||||||
|
text = self._norm(row_text)
|
||||||
|
hits: List[str] = []
|
||||||
|
for ent in normalized_entities:
|
||||||
|
# Only enforce forbidden expansions when the canonical/alias appears in the question.
|
||||||
|
canonical = self._norm(ent.get("canonical", ""))
|
||||||
|
mention = self._norm(ent.get("mention", ""))
|
||||||
|
if canonical not in q and mention not in q:
|
||||||
|
continue
|
||||||
|
for bad in ent.get("forbidden_expansions", []) or []:
|
||||||
|
bad_norm = self._norm(bad)
|
||||||
|
if bad_norm and bad_norm in text:
|
||||||
|
hits.append(bad)
|
||||||
|
return hits
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Production QA V1.1.73 assertion-anchored graph expansion."""
|
||||||
|
|
||||||
|
__version__ = "1.1.73-assertion-anchored"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
from neo4j import GraphDatabase
|
||||||
|
|
||||||
|
from src.rag.dual_kb_taxonomy_rag.entity_alias import EntityAliasNormalizer
|
||||||
|
|
||||||
|
from .schemas import EvidenceItem
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: Any) -> str:
|
||||||
|
return " ".join(_text(value).lower().replace("_", " ").split())
|
||||||
|
|
||||||
|
|
||||||
|
def _type(value: Any) -> str:
|
||||||
|
return _norm(value).replace(" ", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _pmcid(value: Any) -> str:
|
||||||
|
match = re.search(r"\bPMC\d+", _text(value), re.IGNORECASE)
|
||||||
|
return match.group(0).upper() if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
class AgenticGraphRetriever:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
driver: Any | None = None,
|
||||||
|
*,
|
||||||
|
graph_schema: str | None = None,
|
||||||
|
alias_path: str | Path | None = None,
|
||||||
|
):
|
||||||
|
self._owns_driver = driver is None
|
||||||
|
self.graph_schema = (
|
||||||
|
graph_schema or os.environ.get("QA_V1_GRAPH_SCHEMA", "canonical")
|
||||||
|
).strip().lower()
|
||||||
|
resolved_alias_path = alias_path or (
|
||||||
|
Path(__file__).resolve().parents[3] / "configs/rag/entity_alias.yaml"
|
||||||
|
)
|
||||||
|
self.aliaser = EntityAliasNormalizer(resolved_alias_path)
|
||||||
|
self.driver = driver or GraphDatabase.driver(
|
||||||
|
os.environ.get("NEO4J_URI", "bolt://127.0.0.1:18788"),
|
||||||
|
auth=(
|
||||||
|
os.environ.get("NEO4J_USER", "neo4j"),
|
||||||
|
os.environ.get("NEO4J_PASSWORD", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._owns_driver and self.driver is not None:
|
||||||
|
self.driver.close()
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
self, plan: Dict[str, Any]
|
||||||
|
) -> Tuple[List[EvidenceItem], List[Dict[str, Any]], Dict[str, Any]]:
|
||||||
|
anchors = [item for item in plan.get("anchors") or [] if isinstance(item, dict)]
|
||||||
|
anchor_ids = [
|
||||||
|
_text(item.get("canonical_id"))
|
||||||
|
for item in anchors
|
||||||
|
if _text(item.get("canonical_id")).startswith("cent_")
|
||||||
|
]
|
||||||
|
anchor_names = []
|
||||||
|
for item in anchors:
|
||||||
|
for value in (item.get("canonical_name"), item.get("mention")):
|
||||||
|
normalized = _norm(value)
|
||||||
|
if normalized and normalized not in anchor_names:
|
||||||
|
anchor_names.append(normalized)
|
||||||
|
canonical_name = _text(item.get("canonical_name"))
|
||||||
|
alias_entry = self.aliaser.entries.get(canonical_name) or {}
|
||||||
|
for value in alias_entry.get("aliases") or []:
|
||||||
|
normalized = _norm(value)
|
||||||
|
if normalized and normalized not in anchor_names:
|
||||||
|
anchor_names.append(normalized)
|
||||||
|
|
||||||
|
relation_plan = plan.get("relation_plan") if isinstance(plan.get("relation_plan"), dict) else {}
|
||||||
|
graph_plan = plan.get("graph_plan") if isinstance(plan.get("graph_plan"), dict) else {}
|
||||||
|
allow_incoming = bool(relation_plan.get("allow_incoming", True))
|
||||||
|
allow_outgoing = bool(relation_plan.get("allow_outgoing", True))
|
||||||
|
terminal_types = {
|
||||||
|
_type(value)
|
||||||
|
for value in (
|
||||||
|
graph_plan.get("required_terminal_types")
|
||||||
|
or [
|
||||||
|
entity_type
|
||||||
|
for target in plan.get("answer_targets") or []
|
||||||
|
for entity_type in target.get("allowed_entity_types") or []
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if _type(value)
|
||||||
|
}
|
||||||
|
terminal_type_wildcard = "unknown" in terminal_types
|
||||||
|
# Planner uses "unknown" to mean that no terminal type is constrained.
|
||||||
|
terminal_types.discard("unknown")
|
||||||
|
relation_categories = {
|
||||||
|
_norm(value)
|
||||||
|
for value in relation_plan.get("allowed_relation_categories") or []
|
||||||
|
if _norm(value)
|
||||||
|
}
|
||||||
|
audit: Dict[str, Any] = {
|
||||||
|
"enabled": bool(graph_plan.get("enabled", True)),
|
||||||
|
"anchor_ids": anchor_ids,
|
||||||
|
"anchor_names": anchor_names,
|
||||||
|
"allow_incoming": allow_incoming,
|
||||||
|
"allow_outgoing": allow_outgoing,
|
||||||
|
"required_terminal_types": sorted(terminal_types),
|
||||||
|
"terminal_type_wildcard": terminal_type_wildcard,
|
||||||
|
"max_hops": max(1, min(int(graph_plan.get("max_hops", 1)), 3)),
|
||||||
|
"query_mode": (
|
||||||
|
"legacy_anchor_neighborhood"
|
||||||
|
if self.graph_schema == "legacy"
|
||||||
|
else "canonical_anchor_neighborhood"
|
||||||
|
),
|
||||||
|
"preserve_assertion_direction": True,
|
||||||
|
"filtered_reasons": {},
|
||||||
|
}
|
||||||
|
if not audit["enabled"]:
|
||||||
|
audit["status"] = "disabled_by_plan"
|
||||||
|
return [], [], audit
|
||||||
|
if not anchor_ids and not anchor_names:
|
||||||
|
audit["status"] = "no_verified_anchor"
|
||||||
|
return [], [], audit
|
||||||
|
if not allow_incoming and not allow_outgoing:
|
||||||
|
audit["status"] = "no_direction_enabled"
|
||||||
|
return [], [], audit
|
||||||
|
|
||||||
|
rows = self.driver.execute_query(
|
||||||
|
self._statement(),
|
||||||
|
parameters_={
|
||||||
|
"anchor_ids": anchor_ids,
|
||||||
|
"anchor_names": anchor_names,
|
||||||
|
"limit": 100 if self.graph_schema == "legacy" else 300,
|
||||||
|
"layers": [
|
||||||
|
"main_kg",
|
||||||
|
"background_relations",
|
||||||
|
"method_relations",
|
||||||
|
"methodology_relations",
|
||||||
|
"dataset_metadata",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
result_transformer_=lambda result: [dict(record) for record in result],
|
||||||
|
)
|
||||||
|
rows = list(rows or [])
|
||||||
|
evidence: List[EvidenceItem] = []
|
||||||
|
chains: List[Dict[str, Any]] = []
|
||||||
|
seen_assertions = set()
|
||||||
|
|
||||||
|
def filtered(reason: str) -> None:
|
||||||
|
counts = audit["filtered_reasons"]
|
||||||
|
counts[reason] = int(counts.get(reason) or 0) + 1
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
assertion_id = _text(row.get("assertion_id"))
|
||||||
|
if not assertion_id:
|
||||||
|
filtered("missing_assertion_id")
|
||||||
|
continue
|
||||||
|
if assertion_id in seen_assertions:
|
||||||
|
filtered("duplicate_assertion")
|
||||||
|
continue
|
||||||
|
direction = _norm(row.get("direction"))
|
||||||
|
if (direction == "incoming" and not allow_incoming) or (
|
||||||
|
direction == "outgoing" and not allow_outgoing
|
||||||
|
):
|
||||||
|
filtered("direction_disabled")
|
||||||
|
continue
|
||||||
|
if direction not in {"incoming", "outgoing"}:
|
||||||
|
filtered("unknown_direction")
|
||||||
|
continue
|
||||||
|
|
||||||
|
answer_entity = row.get("source") if direction == "incoming" else row.get("target")
|
||||||
|
answer_type = row.get("source_type") if direction == "incoming" else row.get("target_type")
|
||||||
|
if terminal_types and _type(answer_type) not in terminal_types:
|
||||||
|
filtered("terminal_type_mismatch")
|
||||||
|
continue
|
||||||
|
category = _norm(row.get("relation_category"))
|
||||||
|
if relation_categories and category not in relation_categories:
|
||||||
|
filtered("relation_category_mismatch")
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_assertions.add(assertion_id)
|
||||||
|
anchor_name = _text(row.get("anchor_name"))
|
||||||
|
paper_id = _text(row.get("paper_id"))
|
||||||
|
pmcid = _text(row.get("pmcid")) or _pmcid(paper_id)
|
||||||
|
raw = {
|
||||||
|
"assertion_id": assertion_id,
|
||||||
|
"source_type": _text(row.get("source_type")),
|
||||||
|
"target_type": _text(row.get("target_type")),
|
||||||
|
"source_canonical_id": _text(row.get("source_canonical_id")),
|
||||||
|
"target_canonical_id": _text(row.get("target_canonical_id")),
|
||||||
|
"relation_category": _text(row.get("relation_category")),
|
||||||
|
"navigation_direction": direction,
|
||||||
|
"anchor_id": _text(row.get("anchor_id")),
|
||||||
|
"anchor_name": anchor_name,
|
||||||
|
"answer_entity": _text(answer_entity),
|
||||||
|
"answer_entity_type": _type(answer_type),
|
||||||
|
"preserved_assertion_direction": True,
|
||||||
|
}
|
||||||
|
item = EvidenceItem(
|
||||||
|
evidence_id="",
|
||||||
|
kb="V16/V15",
|
||||||
|
source=_text(row.get("source")),
|
||||||
|
relation=_text(row.get("relation")),
|
||||||
|
target=_text(row.get("target")),
|
||||||
|
evidence=_text(row.get("evidence")),
|
||||||
|
paper_id=paper_id,
|
||||||
|
title=_text(row.get("title")),
|
||||||
|
pmid=_text(row.get("pmid")),
|
||||||
|
pmcid=pmcid,
|
||||||
|
doi=_text(row.get("doi")),
|
||||||
|
species=_text(row.get("species")),
|
||||||
|
output_layer="canonical_graph",
|
||||||
|
relation_id=assertion_id,
|
||||||
|
retrieval_methods=["agentic_anchor_graph", direction],
|
||||||
|
final_score=float(row.get("best_score") or 0.0),
|
||||||
|
raw=raw,
|
||||||
|
)
|
||||||
|
evidence.append(item)
|
||||||
|
hop = {
|
||||||
|
"hop_index": 1,
|
||||||
|
"from_id": _text(row.get("source_canonical_id")),
|
||||||
|
"from_name": _text(row.get("source")),
|
||||||
|
"to_id": _text(row.get("target_canonical_id")),
|
||||||
|
"to_name": _text(row.get("target")),
|
||||||
|
"source": _text(row.get("source")),
|
||||||
|
"source_type": _text(row.get("source_type")),
|
||||||
|
"source_canonical_id": _text(row.get("source_canonical_id")),
|
||||||
|
"relation": _text(row.get("relation")),
|
||||||
|
"relation_category": _text(row.get("relation_category")),
|
||||||
|
"target": _text(row.get("target")),
|
||||||
|
"target_type": _text(row.get("target_type")),
|
||||||
|
"target_canonical_id": _text(row.get("target_canonical_id")),
|
||||||
|
"assertion_id": assertion_id,
|
||||||
|
"paper_id": item.paper_id,
|
||||||
|
"title": item.title,
|
||||||
|
"pmid": item.pmid,
|
||||||
|
"pmcid": item.pmcid,
|
||||||
|
"doi": item.doi,
|
||||||
|
"species": item.species,
|
||||||
|
"evidence": item.evidence,
|
||||||
|
"support_count": int(row.get("support_count") or 0),
|
||||||
|
"paper_count": int(row.get("paper_count") or 0),
|
||||||
|
"best_score": float(row.get("best_score") or 0.0),
|
||||||
|
"navigation_direction": direction,
|
||||||
|
"preserved_assertion_direction": True,
|
||||||
|
}
|
||||||
|
chains.append(
|
||||||
|
{
|
||||||
|
"chain_id": f"AG{len(chains) + 1}",
|
||||||
|
"chain_source": "agentic_anchor_graph",
|
||||||
|
"hop_count": 1,
|
||||||
|
"path_score": float(row.get("best_score") or 0.0),
|
||||||
|
"start": anchor_name,
|
||||||
|
"end": _text(answer_entity),
|
||||||
|
"anchor_id": _text(row.get("anchor_id")),
|
||||||
|
"anchor_name": anchor_name,
|
||||||
|
"answer_entity": _text(answer_entity),
|
||||||
|
"answer_entity_type": _type(answer_type),
|
||||||
|
"navigation_direction": direction,
|
||||||
|
"preserve_assertion_direction": True,
|
||||||
|
"paper_id": item.paper_id,
|
||||||
|
"pmid": item.pmid,
|
||||||
|
"pmcid": item.pmcid,
|
||||||
|
"doi": item.doi,
|
||||||
|
"hops": [hop],
|
||||||
|
"score_breakdown": {
|
||||||
|
"anchor_entity_match": 1.0,
|
||||||
|
"answer_type_match": 1.0,
|
||||||
|
"direction_match": 1.0,
|
||||||
|
"final_score": float(row.get("best_score") or 0.0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
audit["raw_assertion_count"] = len(rows)
|
||||||
|
audit["returned_assertion_count"] = len(evidence)
|
||||||
|
audit["returned_chain_count"] = len(chains)
|
||||||
|
audit["status"] = "ok" if evidence else "no_matching_assertions"
|
||||||
|
return evidence, chains, audit
|
||||||
|
|
||||||
|
def _statement(self) -> str:
|
||||||
|
if self.graph_schema == "legacy":
|
||||||
|
return """
|
||||||
|
MATCH (anchor:Entity)
|
||||||
|
WHERE toLower(anchor.name) IN $anchor_names
|
||||||
|
OR toLower(coalesce(anchor.name_norm, anchor.name)) IN $anchor_names
|
||||||
|
WITH anchor LIMIT 24
|
||||||
|
MATCH (anchor)-[r:RELATION]-(neighbor:Entity)
|
||||||
|
WHERE r.output_layer IN $layers
|
||||||
|
WITH anchor, r, startNode(r) AS source_node, endNode(r) AS target_node,
|
||||||
|
CASE
|
||||||
|
WHEN endNode(r) = anchor THEN 'incoming'
|
||||||
|
ELSE 'outgoing'
|
||||||
|
END AS direction
|
||||||
|
RETURN coalesce(anchor.entity_id, anchor.id, '') AS anchor_id,
|
||||||
|
anchor.name AS anchor_name,
|
||||||
|
direction,
|
||||||
|
r.relation_id AS assertion_id,
|
||||||
|
source_node.name AS source,
|
||||||
|
source_node.entity_type AS source_type,
|
||||||
|
coalesce(source_node.entity_id, source_node.id, '') AS source_canonical_id,
|
||||||
|
coalesce(r.relation_norm, r.relation) AS relation,
|
||||||
|
'' AS relation_category,
|
||||||
|
target_node.name AS target,
|
||||||
|
target_node.entity_type AS target_type,
|
||||||
|
coalesce(target_node.entity_id, target_node.id, '') AS target_canonical_id,
|
||||||
|
r.evidence AS evidence,
|
||||||
|
coalesce(r.canonical_paper_id, r.paper_id, '') AS paper_id,
|
||||||
|
'' AS title,
|
||||||
|
coalesce(r.pubmed_id, '') AS pmid,
|
||||||
|
'' AS pmcid,
|
||||||
|
'' AS doi,
|
||||||
|
coalesce(r.species, '') AS species,
|
||||||
|
1 AS support_count,
|
||||||
|
1 AS paper_count,
|
||||||
|
coalesce(r.confidence, 0.0) AS best_score
|
||||||
|
ORDER BY CASE WHEN coalesce(r.evidence, '') = '' THEN 1 ELSE 0 END,
|
||||||
|
coalesce(r.confidence, 0.0) DESC
|
||||||
|
LIMIT $limit
|
||||||
|
"""
|
||||||
|
return """
|
||||||
|
MATCH (a:SRT2CanonicalAssertion)
|
||||||
|
WHERE a.source_canonical_id IN $anchor_ids
|
||||||
|
OR a.target_canonical_id IN $anchor_ids
|
||||||
|
OR toLower(a.source) IN $anchor_names
|
||||||
|
OR toLower(a.target) IN $anchor_names
|
||||||
|
WITH a,
|
||||||
|
CASE
|
||||||
|
WHEN a.target_canonical_id IN $anchor_ids OR toLower(a.target) IN $anchor_names
|
||||||
|
THEN 'incoming'
|
||||||
|
ELSE 'outgoing'
|
||||||
|
END AS direction,
|
||||||
|
CASE
|
||||||
|
WHEN a.target_canonical_id IN $anchor_ids OR toLower(a.target) IN $anchor_names
|
||||||
|
THEN a.target_canonical_id
|
||||||
|
ELSE a.source_canonical_id
|
||||||
|
END AS anchor_id,
|
||||||
|
CASE
|
||||||
|
WHEN a.target_canonical_id IN $anchor_ids OR toLower(a.target) IN $anchor_names
|
||||||
|
THEN a.target
|
||||||
|
ELSE a.source
|
||||||
|
END AS anchor_name
|
||||||
|
RETURN anchor_id, anchor_name, direction,
|
||||||
|
a.assertion_id AS assertion_id,
|
||||||
|
a.source AS source, a.source_type AS source_type,
|
||||||
|
a.source_canonical_id AS source_canonical_id,
|
||||||
|
a.relation_norm AS relation, a.relation_category AS relation_category,
|
||||||
|
a.target AS target, a.target_type AS target_type,
|
||||||
|
a.target_canonical_id AS target_canonical_id,
|
||||||
|
a.best_evidence AS evidence, a.best_paper_id AS paper_id,
|
||||||
|
a.title AS title, a.pmid AS pmid, a.pmcid AS pmcid, a.doi AS doi,
|
||||||
|
'' AS species, a.support_count AS support_count,
|
||||||
|
a.paper_count AS paper_count, a.best_score AS best_score
|
||||||
|
ORDER BY a.paper_count DESC, a.support_count DESC, a.best_score DESC
|
||||||
|
LIMIT $limit
|
||||||
|
"""
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Iterable, List, Sequence
|
||||||
|
|
||||||
|
from .llm_client import LLMClient
|
||||||
|
from .schemas import RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class AgenticHopGateError(RuntimeError):
|
||||||
|
"""Raised when the Agent cannot produce a valid hop decision."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgenticHopGate:
|
||||||
|
"""Let an Agent decide whether verified graph paths need another hop.
|
||||||
|
|
||||||
|
The gate never queries Neo4j and never invents a path. It only reviews a
|
||||||
|
compact representation of paths already verified by the graph retriever.
|
||||||
|
Python retains operational safeguards such as the maximum hop count and
|
||||||
|
cycle prevention, but does not infer the semantic hop count.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SYSTEM = """You are the hop-control Agent for a scientific GraphRAG system.
|
||||||
|
|
||||||
|
Decide whether the CURRENT VERIFIED GRAPH PATHS already support a complete
|
||||||
|
answer to the ORIGINAL QUESTION. Do not predict a hop count before retrieval.
|
||||||
|
Do not answer the question. Do not use outside biological knowledge.
|
||||||
|
|
||||||
|
Choose exactly one action:
|
||||||
|
- stop_answerable: the current paths cover every requested answer slot.
|
||||||
|
- continue: the current paths are useful but at least one answer slot remains
|
||||||
|
unresolved; select only the path IDs whose terminal entities should be
|
||||||
|
expanded by one additional graph hop.
|
||||||
|
- stop_insufficient: the current paths do not support a complete answer and
|
||||||
|
none of their terminal entities provides a defensible next frontier.
|
||||||
|
|
||||||
|
Treat entity spelling or harmless surface-form differences as aliases when the
|
||||||
|
provided path itself makes the identity clear. Preserve relation direction,
|
||||||
|
species, treatment, genotype, tissue, time, negation, and evidence scope.
|
||||||
|
Similarity by itself is not proof. Return JSON only."""
|
||||||
|
|
||||||
|
def __init__(self, llm: LLMClient, *, max_paths: int = 12) -> None:
|
||||||
|
self.llm = llm
|
||||||
|
self.max_paths = max(4, min(int(max_paths or 18), 24))
|
||||||
|
|
||||||
|
def decide(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
paths: Sequence[Any],
|
||||||
|
*,
|
||||||
|
current_hop: int,
|
||||||
|
safety_max_hops: int,
|
||||||
|
task_context: Dict[str, Any] | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
compact_paths = self._compact_paths(paths)
|
||||||
|
if not compact_paths:
|
||||||
|
raise AgenticHopGateError("hop gate received no verified paths")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"original_question": str(question or "").strip(),
|
||||||
|
"question_type": str(
|
||||||
|
(task_context or {}).get("question_type")
|
||||||
|
or hypothesis.question_type
|
||||||
|
or ""
|
||||||
|
),
|
||||||
|
"requested_answer_slots": list(hypothesis.answer_slots or []),
|
||||||
|
"task_answer_slot": str((task_context or {}).get("answer_slot") or ""),
|
||||||
|
"task_constraints": [
|
||||||
|
str(value)
|
||||||
|
for value in ((task_context or {}).get("constraints") or [])
|
||||||
|
if str(value).strip()
|
||||||
|
][:12],
|
||||||
|
"current_hop": int(current_hop),
|
||||||
|
"safety_max_hops": int(safety_max_hops),
|
||||||
|
"can_expand_further": int(current_hop) < int(safety_max_hops),
|
||||||
|
"verified_paths": compact_paths,
|
||||||
|
"required_output_schema": {
|
||||||
|
"action": "stop_answerable|continue|stop_insufficient",
|
||||||
|
"answer_sufficient": False,
|
||||||
|
"covered_answer_slots": ["slot already supported"],
|
||||||
|
"unresolved_answer_slots": ["slot still missing"],
|
||||||
|
"expand_path_ids": ["H1P01"],
|
||||||
|
"next_relation_intents": ["relation concept to seek"],
|
||||||
|
"reason": "brief evidence-grounded decision",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts: List[Dict[str, Any]] = []
|
||||||
|
value: Dict[str, Any] | None = None
|
||||||
|
error = ""
|
||||||
|
for attempt in range(2):
|
||||||
|
request_payload = dict(payload)
|
||||||
|
if attempt:
|
||||||
|
request_payload["format_repair"] = (
|
||||||
|
"The previous response was malformed or internally inconsistent. "
|
||||||
|
"Return one complete JSON object matching required_output_schema."
|
||||||
|
)
|
||||||
|
request_payload["previous_error"] = error
|
||||||
|
raw = self.llm.complete_json(
|
||||||
|
self.SYSTEM,
|
||||||
|
json.dumps(request_payload, ensure_ascii=False),
|
||||||
|
max_tokens=5000,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
value = self._validate(raw, compact_paths, current_hop, safety_max_hops)
|
||||||
|
attempts.append({"attempt": attempt + 1, "valid": True})
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
error = f"{type(exc).__name__}: {exc}"
|
||||||
|
attempts.append(
|
||||||
|
{
|
||||||
|
"attempt": attempt + 1,
|
||||||
|
"valid": False,
|
||||||
|
"error": error[:500],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
value = None
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
raise AgenticHopGateError(
|
||||||
|
f"Agent hop decision invalid after {len(attempts)} attempts: {error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
id_to_index = {
|
||||||
|
str(item["path_id"]): int(item["path_index"])
|
||||||
|
for item in compact_paths
|
||||||
|
}
|
||||||
|
value["expand_path_indexes"] = [
|
||||||
|
id_to_index[path_id] for path_id in value["expand_path_ids"]
|
||||||
|
]
|
||||||
|
value["current_hop"] = int(current_hop)
|
||||||
|
value["safety_max_hops"] = int(safety_max_hops)
|
||||||
|
value["candidate_path_count"] = len(compact_paths)
|
||||||
|
value["json_attempts"] = attempts
|
||||||
|
value["elapsed_seconds"] = time.perf_counter() - started
|
||||||
|
value["decision_source"] = "llm_agent_after_verified_hop"
|
||||||
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate(
|
||||||
|
value: Any,
|
||||||
|
compact_paths: Sequence[Dict[str, Any]],
|
||||||
|
current_hop: int,
|
||||||
|
safety_max_hops: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if not isinstance(value, dict) or value.get("_json_parse_error"):
|
||||||
|
raw_head = (
|
||||||
|
str(value.get("_raw_text_head") or "")[:300]
|
||||||
|
if isinstance(value, dict)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"response is not a JSON object"
|
||||||
|
+ (f"; raw_head={raw_head!r}" if raw_head else "; raw_head is empty")
|
||||||
|
)
|
||||||
|
action = str(value.get("action") or "").strip().lower()
|
||||||
|
if action not in {"stop_answerable", "continue", "stop_insufficient"}:
|
||||||
|
raise ValueError("action is missing or unsupported")
|
||||||
|
sufficient = value.get("answer_sufficient")
|
||||||
|
if not isinstance(sufficient, bool):
|
||||||
|
raise ValueError("answer_sufficient must be a JSON boolean")
|
||||||
|
if action == "stop_answerable" and not sufficient:
|
||||||
|
raise ValueError("stop_answerable requires answer_sufficient=true")
|
||||||
|
if action in {"continue", "stop_insufficient"} and sufficient:
|
||||||
|
raise ValueError(f"{action} requires answer_sufficient=false")
|
||||||
|
|
||||||
|
valid_ids = {str(item.get("path_id") or "") for item in compact_paths}
|
||||||
|
expand_ids = AgenticHopGate._dedupe_strings(value.get("expand_path_ids") or [])
|
||||||
|
unknown_ids = [path_id for path_id in expand_ids if path_id not in valid_ids]
|
||||||
|
if unknown_ids:
|
||||||
|
raise ValueError(f"expand_path_ids contains unknown IDs: {unknown_ids}")
|
||||||
|
if action == "continue":
|
||||||
|
if int(current_hop) >= int(safety_max_hops):
|
||||||
|
raise ValueError("continue is forbidden at the safety hop limit")
|
||||||
|
if not expand_ids:
|
||||||
|
raise ValueError("continue requires at least one verified expand_path_id")
|
||||||
|
elif expand_ids:
|
||||||
|
raise ValueError(f"{action} must not include expand_path_ids")
|
||||||
|
|
||||||
|
reason = str(value.get("reason") or "").strip()
|
||||||
|
if not reason:
|
||||||
|
raise ValueError("reason is required")
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"answer_sufficient": sufficient,
|
||||||
|
"covered_answer_slots": AgenticHopGate._dedupe_strings(
|
||||||
|
value.get("covered_answer_slots") or []
|
||||||
|
)[:12],
|
||||||
|
"unresolved_answer_slots": AgenticHopGate._dedupe_strings(
|
||||||
|
value.get("unresolved_answer_slots") or []
|
||||||
|
)[:12],
|
||||||
|
"expand_path_ids": expand_ids,
|
||||||
|
"next_relation_intents": AgenticHopGate._dedupe_strings(
|
||||||
|
value.get("next_relation_intents") or []
|
||||||
|
)[:12],
|
||||||
|
"reason": reason[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _compact_paths(self, paths: Sequence[Any]) -> List[Dict[str, Any]]:
|
||||||
|
ranked = sorted(
|
||||||
|
enumerate(paths),
|
||||||
|
key=lambda pair: float(getattr(pair[1], "path_score", 0.0) or 0.0),
|
||||||
|
reverse=True,
|
||||||
|
)[: self.max_paths]
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
for display_index, (path_index, path) in enumerate(ranked, start=1):
|
||||||
|
hops = []
|
||||||
|
for hop in list(getattr(path, "hops", []) or []):
|
||||||
|
evidence = " ".join(str(getattr(hop, "evidence", "") or "").split())
|
||||||
|
hops.append(
|
||||||
|
{
|
||||||
|
"source": str(getattr(hop, "source", "") or ""),
|
||||||
|
"relation": str(getattr(hop, "relation", "") or ""),
|
||||||
|
"target": str(getattr(hop, "target", "") or ""),
|
||||||
|
"source_type": str(getattr(hop, "source_type", "") or ""),
|
||||||
|
"target_type": str(getattr(hop, "target_type", "") or ""),
|
||||||
|
"paper_id": str(getattr(hop, "paper_id", "") or ""),
|
||||||
|
"evidence": evidence[:260],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
"path_id": f"H{len(hops)}P{display_index:02d}",
|
||||||
|
"path_index": path_index,
|
||||||
|
"path_score": round(
|
||||||
|
float(getattr(path, "path_score", 0.0) or 0.0), 6
|
||||||
|
),
|
||||||
|
"terminal_entity": (
|
||||||
|
str(getattr(path.hops[-1], "target", "") or "")
|
||||||
|
if getattr(path, "hops", None)
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"hops": hops,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dedupe_strings(values: Iterable[Any]) -> List[str]:
|
||||||
|
output: List[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
key = text.casefold()
|
||||||
|
if text and key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
output.append(text)
|
||||||
|
return output
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from .evidence_pack import format_evidence_pack
|
||||||
|
from .llm_client import LLMClient
|
||||||
|
from .schemas import EvidenceItem, RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class AnswerComposer:
|
||||||
|
SYSTEM = """You are a helpful plant-biology QA assistant. Your task is to answer the user's question using only the provided data report.
|
||||||
|
|
||||||
|
Use a natural explanatory style. Do not answer with a table unless the user explicitly asks for a table.
|
||||||
|
|
||||||
|
Write the answer as substantive bullet points or short paragraphs. Each point should state one supported finding, explain its biological meaning in relation to the user's question, and cite the supporting records.
|
||||||
|
|
||||||
|
Do not merely list SRT triples. Synthesize the provided SRTs and evidence into readable biological statements, but do not add unsupported claims.
|
||||||
|
|
||||||
|
Use all supplied evidence that directly answers the question or materially completes an explanation. Combine redundant records into one finding with shared citations rather than repeating them. Omit evidence that is wholly unrelated; do not mention or cite unrelated records merely to maximize citation coverage.
|
||||||
|
Before writing, silently perform this final-answer consistency check using only
|
||||||
|
the supplied Data Report, GraphChains, and Agent-approved subquestion groups:
|
||||||
|
1. List each directly supported answer slot and ensure that it is stated once
|
||||||
|
in the answer.
|
||||||
|
2. Never call a requested fact unsupported, unavailable, absent, or
|
||||||
|
insufficient when a supplied record directly states that fact.
|
||||||
|
3. For a supported multi-hop GraphChain, follow the continuous chain through
|
||||||
|
its final requested terminal. Do not stop at a bridge entity when the next
|
||||||
|
supplied hop answers the question.
|
||||||
|
4. When only part of a question is supported, answer every supported part
|
||||||
|
first, then limit any uncertainty statement strictly to the unsupported
|
||||||
|
part.
|
||||||
|
|
||||||
|
These are evidence-use checks, not a license to infer missing facts. Do not
|
||||||
|
invent a bridge, relation, or terminal that is not present in the supplied
|
||||||
|
report.
|
||||||
|
|
||||||
|
|
||||||
|
For broad family-function, survey, or open-ended questions, begin with a concise overview and then organize the answer by the distinct functions supported by the evidence. For each function, explain the richest supported process chain available: the relevant context or trigger when present, the function, any intermediate biological process, and the resulting biological outcome. This process-level explanation is valuable even when no specific gene member or molecular target is available.
|
||||||
|
|
||||||
|
For a broad gene-family survey, consolidate the evidence into roughly five to eight major functional or mechanistic themes when the records support that many. Prefer one to three of the most relevant citations per theme and merge redundant family-level records. A complete Chinese answer should normally remain within about 900 to 1500 Chinese characters; an English answer should normally remain within about 500 to 900 words. These are synthesis targets, not reasons to omit a distinct supported mechanism or a directly requested biological process.
|
||||||
|
|
||||||
|
Specific genes, targets, binding sites, and experimental methods are not required unless the question asks for them. When supplied evidence contains useful examples, include them selectively to deepen the explanation without changing a family-level question into an unnecessary gene-by-gene catalogue.
|
||||||
|
|
||||||
|
Match causal language to the evidence. Do not turn a process-level graph chain, association, prediction, or co-occurrence into a direct molecular mechanism. State direct activation, repression, binding, or causation only when the supplied records support it.
|
||||||
|
|
||||||
|
Calibrate answer strength to the evidence level. Expression patterns, association, co-expression, enrichment, and computational prediction support involvement or a candidate role; they do not by themselves support "major", "core", "primary", or causal regulation. Use such strong terms only when the original evidence explicitly establishes them or reports functional perturbation evidence such as a knockout, knockdown, overexpression, complementation, or a directly measured phenotype.
|
||||||
|
|
||||||
|
When the question asks which genes are major but the supplied records contain only expression, association, developmental-context, or heterologous evidence, begin by stating that the evidence does not establish a major regulator. Then list the supported candidates by evidence type. Never call expression evidence direct functional evidence. A heterologous experiment may support a cross-species candidate function, but it is not native-species functional or causal proof.
|
||||||
|
|
||||||
|
Keep developmental scope exact. Evidence for spike development or spikelet development does not automatically establish direct flowering-time regulation. Describe the supported organ or developmental process, and mention flowering-time control only when the evidence directly measures or functionally perturbs flowering time.
|
||||||
|
|
||||||
|
If several records support the same idea, group them into one point and cite the most relevant records. Do not repeat the same idea multiple times.
|
||||||
|
|
||||||
|
If the evidence is partial, say exactly what is supported and what is not supported. If the provided data do not support an answer, say that the provided data are insufficient.
|
||||||
|
|
||||||
|
Preserve species, gene names, host species, tissue, developmental stage, treatment, time point, genotype, negation, quantity, phenotype, and effect direction. Do not merge same-named genes across species.
|
||||||
|
|
||||||
|
Citation rule:
|
||||||
|
Every factual claim based on the report must use only the exact Preferred citation strings supplied with its supporting records. Never invent, alter, infer, or substitute an identifier. Do not expose internal source IDs, graph hop IDs, or dataset-style audit citations in ordinary answer prose.
|
||||||
|
|
||||||
|
Omit factual claims whose Preferred citation is unavailable.
|
||||||
|
|
||||||
|
Do not write paper titles in the answer. Titles are reference-card metadata, not answer prose.
|
||||||
|
|
||||||
|
When the same evidence context supports it, write a concrete statement containing species + gene or family member + supported function or process. Also include supported condition, direction, quantity, mechanism, or phenotype when present. These details are conditional: do not require or invent a specific gene or species when the evidence supports only a family-level statement.
|
||||||
|
|
||||||
|
Claim species are evidence-level facts. Paper species are study context only. Do not promote paper species to claim species. When only Paper species is supplied, phrase it as a study of that species rather than asserting that the SRT finding is species-specific.
|
||||||
|
|
||||||
|
Group redundant findings, but retain every distinct Preferred citation that supports the grouped statement. Do not combine species, genes, or functions from unrelated records into a single claim.
|
||||||
|
|
||||||
|
Do not use unsupported general knowledge. By default, prefer not to use general knowledge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, llm: LLMClient):
|
||||||
|
self.llm = llm
|
||||||
|
|
||||||
|
def compose(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
evidence: List[EvidenceItem],
|
||||||
|
assessment: Dict[str, Any],
|
||||||
|
claim_verification: List[Dict[str, Any]],
|
||||||
|
graph_chain_context: str = "",
|
||||||
|
correction_note: str = "",
|
||||||
|
agent_handoff: Dict[str, Any] | None = None,
|
||||||
|
subquestion_evidence_groups: List[Dict[str, Any]] | None = None,
|
||||||
|
) -> str:
|
||||||
|
context_data = self._build_context_data(evidence, graph_chain_context)
|
||||||
|
allowed_evidence_markers = {
|
||||||
|
marker
|
||||||
|
for item in evidence
|
||||||
|
for marker in (
|
||||||
|
str(item.evidence_id or "").strip(),
|
||||||
|
str(item.relation_id or "").strip(),
|
||||||
|
str((item.raw or {}).get("assertion_id") or "").strip(),
|
||||||
|
)
|
||||||
|
if marker
|
||||||
|
}
|
||||||
|
allowed_chain_ids = set(
|
||||||
|
re.findall(r"^Chain summary\s+(\S+)", graph_chain_context, flags=re.MULTILINE)
|
||||||
|
)
|
||||||
|
subquestion_block = self._build_subquestion_context(
|
||||||
|
subquestion_evidence_groups or [],
|
||||||
|
allowed_evidence_markers=allowed_evidence_markers,
|
||||||
|
allowed_chain_ids=allowed_chain_ids,
|
||||||
|
)
|
||||||
|
correction_block = (
|
||||||
|
f"--- Citation correction required ---\n\n{correction_note}\n\n"
|
||||||
|
if correction_note
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
handoff_block = (
|
||||||
|
"--- Agent-approved final handoff ---\n\n"
|
||||||
|
+ json.dumps(agent_handoff, ensure_ascii=False, indent=2)
|
||||||
|
+ "\n\nUse only the IDs and constraints in this handoff. Do not mention rejected candidates.\n\n"
|
||||||
|
if agent_handoff
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return self.llm.complete(
|
||||||
|
self.SYSTEM,
|
||||||
|
f"""--- Data Report ---
|
||||||
|
|
||||||
|
{context_data}
|
||||||
|
|
||||||
|
--- Retrieval Notes ---
|
||||||
|
|
||||||
|
Question type:
|
||||||
|
{hypothesis.question_type}
|
||||||
|
|
||||||
|
Possible answer hints:
|
||||||
|
{hypothesis.answer_slots}
|
||||||
|
|
||||||
|
Evidence coverage assessment:
|
||||||
|
{assessment}
|
||||||
|
|
||||||
|
Claim verification:
|
||||||
|
{claim_verification}
|
||||||
|
|
||||||
|
These retrieval notes are only hints for selecting relevant records. Do not structure the answer around them mechanically.
|
||||||
|
|
||||||
|
{handoff_block}{subquestion_block}--- Multi-hop Path Rule ---
|
||||||
|
|
||||||
|
When GraphChains are supplied, prefer a continuous path that covers the entities and relations stated in the question in their given order.
|
||||||
|
Answer with the terminal object reached by the final relation asked in the question.
|
||||||
|
Entities already stated in the question are known bridge entities: do not stop at a known bridge when a supported next hop answers the question.
|
||||||
|
Do not combine hops unless the target canonical entity of one hop is the source canonical entity of the next hop.
|
||||||
|
|
||||||
|
--- Answer Style ---
|
||||||
|
|
||||||
|
Answer in the same language as the user's question unless the user requests another language.
|
||||||
|
|
||||||
|
Preserve gene symbols and Latin species names exactly while translating explanatory prose into the user's language.
|
||||||
|
|
||||||
|
Use natural explanatory bullet points or short paragraphs.
|
||||||
|
|
||||||
|
Do not use a table unless the user explicitly asks for one.
|
||||||
|
|
||||||
|
Begin with the direct answer to the user's question. For broad function or survey questions, give a concise overview before the detailed evidence-supported functions.
|
||||||
|
|
||||||
|
Then explain as many distinct, relevant findings as the supplied evidence supports; do not impose a fixed 1-4 point limit. Each point should interpret the evidence in relation to the question, not merely restate a triple.
|
||||||
|
|
||||||
|
Use all records that directly support the answer or materially complete a biological process explanation. Merge duplicate support into shared citations and omit wholly unrelated records. Use only the exact Preferred citation strings printed in the Data Report.
|
||||||
|
|
||||||
|
For each major function, connect the function to intermediate biological processes and outcomes whenever the evidence permits. Specific gene-level details are optional unless requested. Never infer a direct molecular mechanism from a process-level or associative chain alone.
|
||||||
|
|
||||||
|
If the records contain related but not directly answering evidence, mention it only if it helps clarify the answer, and clearly state that it is indirect.
|
||||||
|
|
||||||
|
If the provided data are insufficient, say so directly.
|
||||||
|
|
||||||
|
{correction_block}
|
||||||
|
Now answer the following question:
|
||||||
|
|
||||||
|
{question}
|
||||||
|
""",
|
||||||
|
temperature=0.1,
|
||||||
|
max_tokens=12000,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_subquestion_context(
|
||||||
|
groups: List[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
allowed_evidence_markers: set[str],
|
||||||
|
allowed_chain_ids: set[str],
|
||||||
|
) -> str:
|
||||||
|
if not groups:
|
||||||
|
return ""
|
||||||
|
sections = [
|
||||||
|
"--- Agent-approved subquestion evidence groups ---",
|
||||||
|
"",
|
||||||
|
(
|
||||||
|
"Synthesize these groups around the original question; do not mechanically "
|
||||||
|
"repeat the subquestions. Use only evidence and chains shown in these groups."
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"For mechanism questions, organize supported explanations as: "
|
||||||
|
"trigger/context → molecular action → protected target or process → biological outcome. "
|
||||||
|
"Do not treat expression under stress or a bare tolerance statement as the mechanism. "
|
||||||
|
"Do not add an intermediate step such as binding, degradation, signaling, or refolding "
|
||||||
|
"unless that exact step is explicitly supported by an accepted record in the group."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for group in groups:
|
||||||
|
sections.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
f"Group {group.get('subquestion_id') or '?'}",
|
||||||
|
f"Subquestion: {group.get('question') or ''}",
|
||||||
|
f"Role: {group.get('role') or 'supporting'}",
|
||||||
|
f"Answer slot: {group.get('answer_slot') or ''}",
|
||||||
|
f"Status: {group.get('status') or 'EMPTY'}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
evidence = [
|
||||||
|
item
|
||||||
|
for item in group.get("evidence") or []
|
||||||
|
if isinstance(item, EvidenceItem)
|
||||||
|
]
|
||||||
|
evidence_references: List[str] = []
|
||||||
|
for item in evidence:
|
||||||
|
markers = {
|
||||||
|
str(item.evidence_id or "").strip(),
|
||||||
|
str(item.relation_id or "").strip(),
|
||||||
|
str((item.raw or {}).get("assertion_id") or "").strip(),
|
||||||
|
}
|
||||||
|
markers.discard("")
|
||||||
|
if not markers.intersection(allowed_evidence_markers):
|
||||||
|
continue
|
||||||
|
reference = (
|
||||||
|
str(item.relation_id or "").strip()
|
||||||
|
or str((item.raw or {}).get("assertion_id") or "").strip()
|
||||||
|
or str(item.evidence_id or "").strip()
|
||||||
|
)
|
||||||
|
evidence_references.append(
|
||||||
|
f"- {reference}: {item.source} -- {item.relation} --> {item.target}"
|
||||||
|
)
|
||||||
|
if evidence_references:
|
||||||
|
sections.append(
|
||||||
|
"Accepted evidence references (full records appear once in the Data Report):\n"
|
||||||
|
+ "\n".join(evidence_references)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sections.append("Accepted evidence references: none")
|
||||||
|
chain_ids = [
|
||||||
|
str(chain.get("chain_id") or "").strip()
|
||||||
|
for chain in group.get("chains") or []
|
||||||
|
if isinstance(chain, dict)
|
||||||
|
and str(chain.get("chain_id") or "").strip() in allowed_chain_ids
|
||||||
|
]
|
||||||
|
if chain_ids:
|
||||||
|
sections.append(
|
||||||
|
"Accepted chain references (full chains appear once in GraphChains): "
|
||||||
|
+ ", ".join(dict.fromkeys(chain_ids))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sections.append("Accepted chain references: none")
|
||||||
|
return "\n".join(sections).strip() + "\n\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_context_data(evidence: List[EvidenceItem], graph_chain_context: str) -> str:
|
||||||
|
sections: List[str] = []
|
||||||
|
if evidence:
|
||||||
|
sections.append("Dataset: Sources\n" + format_evidence_pack(evidence))
|
||||||
|
else:
|
||||||
|
sections.append("Dataset: Sources\nNo separate top-k SRT evidence was supplied for this answer.")
|
||||||
|
if graph_chain_context:
|
||||||
|
sections.append("Dataset: GraphChains\n" + graph_chain_context)
|
||||||
|
else:
|
||||||
|
sections.append("Dataset: GraphChains\nNo graph knowledge chain was available.")
|
||||||
|
return "\n\n".join(sections).strip()
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .config import QAConfig
|
||||||
|
from .pipeline import QAAnswerError, QAV1Pipeline
|
||||||
|
|
||||||
|
|
||||||
|
class QARequest(BaseModel):
|
||||||
|
question: str = Field(min_length=1)
|
||||||
|
debug: bool = False
|
||||||
|
use_provisional_draft: Optional[bool] = None
|
||||||
|
max_rounds: Optional[int] = Field(default=None, ge=1, le=2)
|
||||||
|
ablation: Optional[Dict[str, Any]] = None
|
||||||
|
use_llm: Optional[bool] = None
|
||||||
|
chain_top_k: Optional[int] = Field(default=3, ge=0, le=20)
|
||||||
|
chain_hops: Optional[int] = Field(default=2, ge=1, le=2)
|
||||||
|
chain_max_edges: Optional[int] = Field(default=12, ge=0, le=120)
|
||||||
|
chain_max_nodes: Optional[int] = Field(default=10, ge=0, le=80)
|
||||||
|
chain_mode: Optional[str] = Field(default="display")
|
||||||
|
chain_min_score: Optional[float] = Field(default=0.45, ge=0.0, le=1.0)
|
||||||
|
native_chain_enabled: bool = True
|
||||||
|
native_chain_max_hops: int = Field(default=3, ge=1, le=3)
|
||||||
|
native_chain_top_k: int = Field(default=5, ge=0, le=20)
|
||||||
|
native_chain_min_hops: int = Field(default=1, ge=1, le=3)
|
||||||
|
native_chain_beam_width: int = Field(default=60, ge=1, le=300)
|
||||||
|
native_chain_per_node_expand: int = Field(default=12, ge=1, le=50)
|
||||||
|
endpoint_path_enabled: bool = False
|
||||||
|
endpoint_path_max_hops: int = Field(default=3, ge=1, le=3)
|
||||||
|
endpoint_path_top_k: int = Field(default=5, ge=0, le=10)
|
||||||
|
endpoint_path_max_endpoint_candidates: int = Field(default=3, ge=1, le=5)
|
||||||
|
endpoint_path_per_node_limit: int = Field(default=16, ge=2, le=50)
|
||||||
|
endpoint_path_max_expansions: int = Field(default=24, ge=10, le=1000)
|
||||||
|
endpoint_path_max_frontier_states: int = Field(default=120, ge=20, le=2000)
|
||||||
|
endpoint_path_time_budget_seconds: float = Field(default=5.0, ge=1.0, le=30.0)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_pipeline() -> QAV1Pipeline:
|
||||||
|
return QAV1Pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/qa-v1-1-73", tags=["QA V1.1.73"] )
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health() -> Dict[str, Any]:
|
||||||
|
return {"status": "ok", "service": "qa_v1_1_73", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/info")
|
||||||
|
def info() -> Dict[str, Any]:
|
||||||
|
config = QAConfig.from_env()
|
||||||
|
return {
|
||||||
|
"name": "QA V1.1.73 Assertion-Anchored Full Graph",
|
||||||
|
"version": __version__,
|
||||||
|
"pipeline": [
|
||||||
|
"question",
|
||||||
|
"provisional answer",
|
||||||
|
"structured planner",
|
||||||
|
"direct Qdrant recall over V15/V16 canonical assertions",
|
||||||
|
"canonical seed extraction",
|
||||||
|
"Neo4j exact seed retrieval",
|
||||||
|
"Neo4j bounded graph expansion",
|
||||||
|
"BGE rerank",
|
||||||
|
"graph-balanced top30 evidence chain, with at least 15 graph-derived items when available",
|
||||||
|
"BGE top10 Qdrant source seed selection for cascaded native Neo4j two-hop chain retrieval",
|
||||||
|
"BGE top10 complete assertions locked as hop 1 and expanded from each target in Neo4j",
|
||||||
|
"merge assertion-anchored chains with the unchanged formal native graph lane",
|
||||||
|
"compose a validated final LLM answer for chat; retrieval-only search returns evidence without an answer",
|
||||||
|
],
|
||||||
|
"safety_boundary": (
|
||||||
|
"The provisional answer is never a final evidence source. "
|
||||||
|
"The original-question retrieval lane is always retained."
|
||||||
|
),
|
||||||
|
"config": {
|
||||||
|
"alias_path": str(config.alias_path),
|
||||||
|
"taxonomy_path": str(config.taxonomy_path),
|
||||||
|
"reranker_model": str(config.reranker_model),
|
||||||
|
"max_rounds": config.max_rounds,
|
||||||
|
"max_evidence": config.max_evidence,
|
||||||
|
"provisional_draft": config.enable_provisional_draft,
|
||||||
|
"second_round": config.enable_second_round,
|
||||||
|
"bge_rerank": config.enable_bge_rerank,
|
||||||
|
"graph_schema": config.graph_schema,
|
||||||
|
"knowledge_chain_defaults": {
|
||||||
|
"chain_top_k": 3,
|
||||||
|
"chain_hops": 2,
|
||||||
|
"chain_max_edges": 12,
|
||||||
|
"chain_max_nodes": 10,
|
||||||
|
"chain_mode": "display",
|
||||||
|
"used_for_answer_generation": True,
|
||||||
|
"graph_min_evidence_in_top30": 15,
|
||||||
|
"native_chain_max_hops": 3,
|
||||||
|
"native_chain_top_k": 5,
|
||||||
|
"native_chain_min_hops": 1,
|
||||||
|
"endpoint_path_enabled": False,
|
||||||
|
"endpoint_path_max_hops": 3,
|
||||||
|
"endpoint_path_top_k": 5,
|
||||||
|
},
|
||||||
|
"request_ablation_supported": [
|
||||||
|
"use_provisional_draft",
|
||||||
|
"max_rounds",
|
||||||
|
"ablation.retrieval.use_qdrant",
|
||||||
|
"ablation.retrieval.use_neo4j",
|
||||||
|
"ablation.retrieval.use_graph_expansion",
|
||||||
|
"ablation.retrieval.use_v15",
|
||||||
|
"ablation.use_bge_rerank",
|
||||||
|
"ablation.native_seed_policy",
|
||||||
|
"ablation.native_seed_top_k",
|
||||||
|
"ablation.native_target_seed_policy",
|
||||||
|
"endpoint_path_enabled",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat")
|
||||||
|
async def chat(request: QARequest) -> Dict[str, Any]:
|
||||||
|
if request.use_llm is False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"error_code": "llm_required", "answer": "", "message": "Final-answer LLM is required"},
|
||||||
|
)
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
if not pipeline.llm.available:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail={"error_code": "llm_unavailable", "answer": "", "message": "Final-answer LLM is not configured"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ablation = dict(request.ablation or {})
|
||||||
|
ablation["use_final_llm"] = True
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
pipeline.answer,
|
||||||
|
request.question,
|
||||||
|
debug=request.debug,
|
||||||
|
use_provisional_draft=request.use_provisional_draft,
|
||||||
|
max_rounds=request.max_rounds,
|
||||||
|
ablation=ablation,
|
||||||
|
chain_top_k=request.chain_top_k,
|
||||||
|
chain_hops=request.chain_hops,
|
||||||
|
chain_max_edges=request.chain_max_edges,
|
||||||
|
chain_max_nodes=request.chain_max_nodes,
|
||||||
|
chain_mode=request.chain_mode,
|
||||||
|
chain_min_score=request.chain_min_score,
|
||||||
|
native_chain_enabled=request.native_chain_enabled,
|
||||||
|
native_chain_max_hops=request.native_chain_max_hops,
|
||||||
|
native_chain_top_k=request.native_chain_top_k,
|
||||||
|
native_chain_min_hops=request.native_chain_min_hops,
|
||||||
|
native_chain_beam_width=request.native_chain_beam_width,
|
||||||
|
native_chain_per_node_expand=request.native_chain_per_node_expand,
|
||||||
|
endpoint_path_enabled=request.endpoint_path_enabled,
|
||||||
|
endpoint_path_max_hops=request.endpoint_path_max_hops,
|
||||||
|
endpoint_path_top_k=request.endpoint_path_top_k,
|
||||||
|
endpoint_path_max_endpoint_candidates=request.endpoint_path_max_endpoint_candidates,
|
||||||
|
endpoint_path_per_node_limit=request.endpoint_path_per_node_limit,
|
||||||
|
endpoint_path_max_expansions=request.endpoint_path_max_expansions,
|
||||||
|
endpoint_path_max_frontier_states=request.endpoint_path_max_frontier_states,
|
||||||
|
endpoint_path_time_budget_seconds=request.endpoint_path_time_budget_seconds,
|
||||||
|
)
|
||||||
|
except QAAnswerError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
detail={"error_code": exc.error_code, "answer": "", "message": str(exc)},
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={"error_code": "qa_failure", "answer": "", "message": str(exc)},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/stream")
|
||||||
|
async def chat_stream(request: QARequest) -> StreamingResponse:
|
||||||
|
"""Stream truthful pipeline milestones, then the unchanged chat payload."""
|
||||||
|
if request.use_llm is False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"error_code": "llm_required", "answer": "", "message": "Final-answer LLM is required"},
|
||||||
|
)
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
if not pipeline.llm.available:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail={"error_code": "llm_unavailable", "answer": "", "message": "Final-answer LLM is not configured"},
|
||||||
|
)
|
||||||
|
|
||||||
|
events: queue.Queue[tuple[str, Dict[str, Any]] | None] = queue.Queue()
|
||||||
|
|
||||||
|
def emit(payload: Dict[str, Any]) -> None:
|
||||||
|
events.put(("progress", payload))
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
try:
|
||||||
|
ablation = dict(request.ablation or {})
|
||||||
|
ablation["use_final_llm"] = True
|
||||||
|
result = pipeline.answer(
|
||||||
|
request.question,
|
||||||
|
debug=request.debug,
|
||||||
|
use_provisional_draft=request.use_provisional_draft,
|
||||||
|
max_rounds=request.max_rounds,
|
||||||
|
ablation=ablation,
|
||||||
|
chain_top_k=request.chain_top_k,
|
||||||
|
chain_hops=request.chain_hops,
|
||||||
|
chain_max_edges=request.chain_max_edges,
|
||||||
|
chain_max_nodes=request.chain_max_nodes,
|
||||||
|
chain_mode=request.chain_mode,
|
||||||
|
chain_min_score=request.chain_min_score,
|
||||||
|
native_chain_enabled=request.native_chain_enabled,
|
||||||
|
native_chain_max_hops=request.native_chain_max_hops,
|
||||||
|
native_chain_top_k=request.native_chain_top_k,
|
||||||
|
native_chain_min_hops=request.native_chain_min_hops,
|
||||||
|
native_chain_beam_width=request.native_chain_beam_width,
|
||||||
|
native_chain_per_node_expand=request.native_chain_per_node_expand,
|
||||||
|
endpoint_path_enabled=request.endpoint_path_enabled,
|
||||||
|
endpoint_path_max_hops=request.endpoint_path_max_hops,
|
||||||
|
endpoint_path_top_k=request.endpoint_path_top_k,
|
||||||
|
endpoint_path_max_endpoint_candidates=request.endpoint_path_max_endpoint_candidates,
|
||||||
|
endpoint_path_per_node_limit=request.endpoint_path_per_node_limit,
|
||||||
|
endpoint_path_max_expansions=request.endpoint_path_max_expansions,
|
||||||
|
endpoint_path_max_frontier_states=request.endpoint_path_max_frontier_states,
|
||||||
|
endpoint_path_time_budget_seconds=request.endpoint_path_time_budget_seconds,
|
||||||
|
progress_callback=emit,
|
||||||
|
)
|
||||||
|
events.put(("complete", result))
|
||||||
|
except QAAnswerError as exc:
|
||||||
|
events.put(("error", {
|
||||||
|
"success": False,
|
||||||
|
"status": exc.status_code,
|
||||||
|
"error_code": exc.error_code,
|
||||||
|
"message": str(exc),
|
||||||
|
"answer": "",
|
||||||
|
}))
|
||||||
|
except Exception as exc:
|
||||||
|
events.put(("error", {
|
||||||
|
"success": False,
|
||||||
|
"status": 500,
|
||||||
|
"error_code": "qa_failure",
|
||||||
|
"message": str(exc),
|
||||||
|
"answer": "",
|
||||||
|
}))
|
||||||
|
finally:
|
||||||
|
events.put(None)
|
||||||
|
|
||||||
|
threading.Thread(target=run, name="qa-v1-1-73-stream", daemon=True).start()
|
||||||
|
|
||||||
|
def stream():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
item = events.get(timeout=15)
|
||||||
|
except queue.Empty:
|
||||||
|
yield ": keepalive\n\n"
|
||||||
|
continue
|
||||||
|
if item is None:
|
||||||
|
break
|
||||||
|
event_name, payload = item
|
||||||
|
yield (
|
||||||
|
f"event: {event_name}\n"
|
||||||
|
f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/search")
|
||||||
|
async def search(request: QARequest) -> Dict[str, Any]:
|
||||||
|
response = await asyncio.to_thread(
|
||||||
|
get_pipeline().answer,
|
||||||
|
request.question,
|
||||||
|
debug=True,
|
||||||
|
use_provisional_draft=request.use_provisional_draft,
|
||||||
|
max_rounds=request.max_rounds,
|
||||||
|
ablation=dict(request.ablation or {}),
|
||||||
|
chain_top_k=request.chain_top_k,
|
||||||
|
chain_hops=request.chain_hops,
|
||||||
|
chain_max_edges=request.chain_max_edges,
|
||||||
|
chain_max_nodes=request.chain_max_nodes,
|
||||||
|
chain_mode=request.chain_mode,
|
||||||
|
chain_min_score=request.chain_min_score,
|
||||||
|
native_chain_enabled=request.native_chain_enabled,
|
||||||
|
native_chain_max_hops=request.native_chain_max_hops,
|
||||||
|
native_chain_top_k=request.native_chain_top_k,
|
||||||
|
native_chain_min_hops=request.native_chain_min_hops,
|
||||||
|
native_chain_beam_width=request.native_chain_beam_width,
|
||||||
|
native_chain_per_node_expand=request.native_chain_per_node_expand,
|
||||||
|
endpoint_path_enabled=request.endpoint_path_enabled,
|
||||||
|
endpoint_path_max_hops=request.endpoint_path_max_hops,
|
||||||
|
endpoint_path_top_k=request.endpoint_path_top_k,
|
||||||
|
endpoint_path_max_endpoint_candidates=request.endpoint_path_max_endpoint_candidates,
|
||||||
|
endpoint_path_per_node_limit=request.endpoint_path_per_node_limit,
|
||||||
|
endpoint_path_max_expansions=request.endpoint_path_max_expansions,
|
||||||
|
endpoint_path_max_frontier_states=request.endpoint_path_max_frontier_states,
|
||||||
|
endpoint_path_time_budget_seconds=request.endpoint_path_time_budget_seconds,
|
||||||
|
retrieval_only=True,
|
||||||
|
)
|
||||||
|
if isinstance(response, dict):
|
||||||
|
response["answer"] = ""
|
||||||
|
return response
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
from neo4j import Query
|
||||||
|
|
||||||
|
from .bounded_canonical_retriever import BoundedCanonicalGraphPathRetriever
|
||||||
|
from .graph_path_chain import BLOCKED_CATEGORIES, GraphHop, GraphPath, _norm, _text
|
||||||
|
from .schemas import RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
UNKNOWN_ANSWER_TYPES = {
|
||||||
|
"",
|
||||||
|
"any",
|
||||||
|
"none",
|
||||||
|
"not specified",
|
||||||
|
"unspecified",
|
||||||
|
"unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AssertionAnchoredGraphRetriever(BoundedCanonicalGraphPathRetriever):
|
||||||
|
"""Lock reranked vector assertions as hop 1, then expand their targets.
|
||||||
|
|
||||||
|
Qdrant and Neo4j were built from the same canonical assertion corpus. The
|
||||||
|
vector assertion ID is therefore a reliable cross-store join key. The
|
||||||
|
public query entrance remains natural-language/vector retrieval; canonical
|
||||||
|
entity IDs are used only after Neo4j has verified the selected assertion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHAIN_SOURCE = "neo4j_vector_assertion_anchored_path"
|
||||||
|
|
||||||
|
def retrieve_from_assertions(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
assertion_records: Iterable[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
relation_categories: Iterable[str] | None = None,
|
||||||
|
answer_type_groups: Iterable[str] | None = None,
|
||||||
|
max_hops: int = 3,
|
||||||
|
per_node_expand: int = 12,
|
||||||
|
beam_width: int = 80,
|
||||||
|
top_k_chains: int = 10,
|
||||||
|
hop_gate: Any | None = None,
|
||||||
|
hop_gate_context: Dict[str, Any] | None = None,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
max_hops = max(1, min(int(max_hops or 3), 3))
|
||||||
|
per_node_expand = max(1, min(int(per_node_expand or 12), 30))
|
||||||
|
beam_width = max(10, min(int(beam_width or 80), 200))
|
||||||
|
top_k_chains = max(0, min(int(top_k_chains or 10), 20))
|
||||||
|
records = self._clean_assertion_records(assertion_records)[:10]
|
||||||
|
answer_types = [
|
||||||
|
_norm(value)
|
||||||
|
for value in (answer_type_groups or [])
|
||||||
|
if _norm(value) not in UNKNOWN_ANSWER_TYPES
|
||||||
|
]
|
||||||
|
audit: Dict[str, Any] = {
|
||||||
|
"enabled": True,
|
||||||
|
"chain_source": self.CHAIN_SOURCE,
|
||||||
|
"policy": "qdrant_bge_top10_assertion_locked_as_hop1_then_target_expansion",
|
||||||
|
"requested_assertion_count": len(records),
|
||||||
|
"requested_assertion_ids": [row["assertion_id"] for row in records],
|
||||||
|
"max_hops": max_hops,
|
||||||
|
"per_node_expand": per_node_expand,
|
||||||
|
"beam_width": beam_width,
|
||||||
|
"top_k_chains": top_k_chains,
|
||||||
|
"answer_type_groups": answer_types,
|
||||||
|
"hop_stats": [],
|
||||||
|
"agentic_hop_gate": {
|
||||||
|
"enabled": hop_gate is not None,
|
||||||
|
"decisions": [],
|
||||||
|
},
|
||||||
|
"filtered_reasons": {},
|
||||||
|
"fallback_required": False,
|
||||||
|
}
|
||||||
|
if top_k_chains <= 0:
|
||||||
|
audit["status"] = "disabled_by_top_k"
|
||||||
|
return [], audit
|
||||||
|
if not records:
|
||||||
|
audit.update(
|
||||||
|
{
|
||||||
|
"status": "no_vector_assertions",
|
||||||
|
"fallback_required": True,
|
||||||
|
"elapsed_seconds": time.perf_counter() - started,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return [], audit
|
||||||
|
|
||||||
|
self._bounded_reset_metrics()
|
||||||
|
self._bounded_prepare_question(question)
|
||||||
|
first_rows = self._load_verified_first_hops(records)
|
||||||
|
audit["verified_first_hop_count"] = len(first_rows)
|
||||||
|
audit["verified_first_hop_assertion_ids"] = [
|
||||||
|
_text(row.get("assertion_id")) for row in first_rows
|
||||||
|
]
|
||||||
|
if not first_rows:
|
||||||
|
audit.update(
|
||||||
|
{
|
||||||
|
"status": "no_assertion_mapped_to_neo4j",
|
||||||
|
"fallback_required": True,
|
||||||
|
"bounded_graph_backend": dict(self._bounded_metrics),
|
||||||
|
"elapsed_seconds": time.perf_counter() - started,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return [], audit
|
||||||
|
|
||||||
|
question_norm = _norm(question)
|
||||||
|
allowed = self._allowed_categories(hypothesis, relation_categories)
|
||||||
|
allowed.update(self._categories_from_question(question_norm))
|
||||||
|
relation_clues = self._relation_clues(question)
|
||||||
|
priority_relation_clues = self._priority_relation_clues(question_norm)
|
||||||
|
seed_norms = {
|
||||||
|
value
|
||||||
|
for row in first_rows
|
||||||
|
for value in (_norm(row.get("source")), _norm(row.get("target")))
|
||||||
|
if value
|
||||||
|
}
|
||||||
|
entity_clues = self._visible_entity_clues(question_norm, seed_norms)
|
||||||
|
|
||||||
|
first_paths: List[GraphPath] = []
|
||||||
|
for row in first_rows:
|
||||||
|
edge_score, edge_breakdown = self._score_edge(
|
||||||
|
row, question_norm, seed_norms, allowed
|
||||||
|
)
|
||||||
|
vector_score = float(row.get("seed_rerank_score") or 0.0)
|
||||||
|
edge_score = min(1.0, 0.55 * edge_score + 0.45 * vector_score)
|
||||||
|
edge_breakdown.update(
|
||||||
|
{
|
||||||
|
"vector_assertion_first_hop_verified": 1.0,
|
||||||
|
"vector_rerank_score": vector_score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hop = self._row_to_hop(row, hop_index=1, edge_score=edge_score, edge_breakdown=edge_breakdown)
|
||||||
|
path = GraphPath(
|
||||||
|
hops=[hop],
|
||||||
|
node_ids=[hop.source_canonical_id, hop.target_canonical_id],
|
||||||
|
)
|
||||||
|
path.path_score, path.score_breakdown = self._score_anchored_path(
|
||||||
|
path,
|
||||||
|
question_norm,
|
||||||
|
seed_norms,
|
||||||
|
allowed,
|
||||||
|
relation_clues,
|
||||||
|
answer_types,
|
||||||
|
entity_clues,
|
||||||
|
vector_score,
|
||||||
|
)
|
||||||
|
first_paths.append(path)
|
||||||
|
|
||||||
|
all_paths: List[GraphPath] = list(first_paths)
|
||||||
|
beam = self._select_beam_paths(
|
||||||
|
list(first_paths), beam_width, entity_clues=entity_clues
|
||||||
|
)
|
||||||
|
filtered: Dict[str, int] = {}
|
||||||
|
if hop_gate is not None and beam:
|
||||||
|
decision = hop_gate.decide(
|
||||||
|
question,
|
||||||
|
hypothesis,
|
||||||
|
beam,
|
||||||
|
current_hop=1,
|
||||||
|
safety_max_hops=max_hops,
|
||||||
|
task_context=hop_gate_context or {},
|
||||||
|
)
|
||||||
|
audit["agentic_hop_gate"]["decisions"].append(
|
||||||
|
self._public_hop_gate_decision(decision)
|
||||||
|
)
|
||||||
|
if decision["action"] == "continue":
|
||||||
|
beam = self._paths_at_indexes(
|
||||||
|
beam, decision.get("expand_path_indexes") or []
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
beam = []
|
||||||
|
|
||||||
|
for hop_index in range(2, max_hops + 1):
|
||||||
|
frontier_ids = self._dedupe([path.end_id for path in beam if path.end_id])
|
||||||
|
if not frontier_ids:
|
||||||
|
break
|
||||||
|
rows = self._fetch_neighbors(
|
||||||
|
frontier_ids,
|
||||||
|
allowed,
|
||||||
|
relation_clues=relation_clues,
|
||||||
|
priority_relation_clues=priority_relation_clues,
|
||||||
|
entity_clues=entity_clues,
|
||||||
|
answer_type_groups=answer_types,
|
||||||
|
per_node_expand=per_node_expand,
|
||||||
|
)
|
||||||
|
paths_by_end: Dict[str, List[GraphPath]] = {}
|
||||||
|
for path in beam:
|
||||||
|
paths_by_end.setdefault(path.end_id, []).append(path)
|
||||||
|
next_paths: List[GraphPath] = []
|
||||||
|
for row in rows:
|
||||||
|
from_id = _text(row.get("from_id"))
|
||||||
|
to_id = _text(row.get("to_id"))
|
||||||
|
if not from_id or not to_id:
|
||||||
|
self._inc(filtered, "missing_node")
|
||||||
|
continue
|
||||||
|
if _text(row.get("relation_category")) in BLOCKED_CATEGORIES:
|
||||||
|
self._inc(filtered, "blocked_relation_category")
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
_text(row.get("source_canonical_id")) != from_id
|
||||||
|
or _text(row.get("target_canonical_id")) != to_id
|
||||||
|
):
|
||||||
|
self._inc(filtered, "non_directed_srt_continuity")
|
||||||
|
continue
|
||||||
|
for base in paths_by_end.get(from_id, []):
|
||||||
|
assertion_id = _text(row.get("assertion_id"))
|
||||||
|
if assertion_id and assertion_id in base.assertion_ids():
|
||||||
|
self._inc(filtered, "repeat_assertion")
|
||||||
|
continue
|
||||||
|
if to_id in base.node_ids:
|
||||||
|
self._inc(filtered, "repeat_node")
|
||||||
|
continue
|
||||||
|
edge_score, edge_breakdown = self._score_edge(
|
||||||
|
row, question_norm, seed_norms, allowed
|
||||||
|
)
|
||||||
|
relation_tokens = {
|
||||||
|
token
|
||||||
|
for token in _norm(row.get("relation")).split()
|
||||||
|
if len(token) >= 4
|
||||||
|
}
|
||||||
|
question_tokens = set(question_norm.split())
|
||||||
|
relation_question_overlap = (
|
||||||
|
len(relation_tokens & question_tokens)
|
||||||
|
/ max(1, len(relation_tokens))
|
||||||
|
)
|
||||||
|
edge_score = min(
|
||||||
|
1.0, edge_score + 0.18 * relation_question_overlap
|
||||||
|
)
|
||||||
|
edge_breakdown["relation_question_token_overlap"] = (
|
||||||
|
relation_question_overlap
|
||||||
|
)
|
||||||
|
if edge_score < 0.12:
|
||||||
|
self._inc(filtered, "low_edge_score")
|
||||||
|
continue
|
||||||
|
hop = self._row_to_hop(
|
||||||
|
row,
|
||||||
|
hop_index=hop_index,
|
||||||
|
edge_score=edge_score,
|
||||||
|
edge_breakdown=edge_breakdown,
|
||||||
|
)
|
||||||
|
path = GraphPath(
|
||||||
|
hops=base.hops + [hop],
|
||||||
|
node_ids=base.node_ids + [to_id],
|
||||||
|
)
|
||||||
|
vector_score = float(
|
||||||
|
base.score_breakdown.get("vector_first_hop_score") or 0.0
|
||||||
|
)
|
||||||
|
path.path_score, path.score_breakdown = self._score_anchored_path(
|
||||||
|
path,
|
||||||
|
question_norm,
|
||||||
|
seed_norms,
|
||||||
|
allowed,
|
||||||
|
relation_clues,
|
||||||
|
answer_types,
|
||||||
|
entity_clues,
|
||||||
|
vector_score,
|
||||||
|
)
|
||||||
|
next_paths.append(path)
|
||||||
|
next_paths = self._dedupe_paths(next_paths)
|
||||||
|
next_paths.sort(key=lambda item: item.path_score, reverse=True)
|
||||||
|
all_paths.extend(next_paths)
|
||||||
|
candidate_beam = self._select_beam_paths(
|
||||||
|
next_paths,
|
||||||
|
beam_width,
|
||||||
|
entity_clues=entity_clues,
|
||||||
|
)
|
||||||
|
if hop_gate is not None and candidate_beam:
|
||||||
|
decision = hop_gate.decide(
|
||||||
|
question,
|
||||||
|
hypothesis,
|
||||||
|
candidate_beam,
|
||||||
|
current_hop=hop_index,
|
||||||
|
safety_max_hops=max_hops,
|
||||||
|
task_context=hop_gate_context or {},
|
||||||
|
)
|
||||||
|
audit["agentic_hop_gate"]["decisions"].append(
|
||||||
|
self._public_hop_gate_decision(decision)
|
||||||
|
)
|
||||||
|
if decision["action"] == "continue":
|
||||||
|
beam = self._paths_at_indexes(
|
||||||
|
candidate_beam,
|
||||||
|
decision.get("expand_path_indexes") or [],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
beam = []
|
||||||
|
else:
|
||||||
|
beam = candidate_beam
|
||||||
|
audit["hop_stats"].append(
|
||||||
|
{
|
||||||
|
"hop": hop_index,
|
||||||
|
"frontier_count": len(frontier_ids),
|
||||||
|
"raw_edge_count": len(rows),
|
||||||
|
"candidate_path_count": len(next_paths),
|
||||||
|
"kept_beam_count": len(beam),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not beam:
|
||||||
|
break
|
||||||
|
|
||||||
|
all_paths = self._dedupe_paths(all_paths)
|
||||||
|
selected = self._select_diverse_paths(all_paths, top_k_chains)
|
||||||
|
chains = []
|
||||||
|
for index, path in enumerate(selected, start=1):
|
||||||
|
chain = path.to_chain_dict(index)
|
||||||
|
chain["chain_source"] = self.CHAIN_SOURCE
|
||||||
|
chain["first_hop_assertion_id"] = path.hops[0].assertion_id if path.hops else ""
|
||||||
|
chain["first_hop_locked"] = True
|
||||||
|
chains.append(chain)
|
||||||
|
|
||||||
|
audit.update(
|
||||||
|
{
|
||||||
|
"status": "ok" if chains else "no_expanded_chain",
|
||||||
|
"fallback_required": not bool(chains),
|
||||||
|
"candidate_path_count": len(all_paths),
|
||||||
|
"returned_chain_count": len(chains),
|
||||||
|
"selected_hop_counts": {
|
||||||
|
str(hop): sum(1 for path in selected if len(path.hops) == hop)
|
||||||
|
for hop in range(1, max_hops + 1)
|
||||||
|
},
|
||||||
|
"filtered_reasons": filtered,
|
||||||
|
"bounded_graph_backend": dict(self._bounded_metrics),
|
||||||
|
"elapsed_seconds": time.perf_counter() - started,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return chains, audit
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _paths_at_indexes(paths: List[GraphPath], indexes: Iterable[Any]) -> List[GraphPath]:
|
||||||
|
selected: List[GraphPath] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for value in indexes:
|
||||||
|
try:
|
||||||
|
index = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if index in seen or index < 0 or index >= len(paths):
|
||||||
|
continue
|
||||||
|
seen.add(index)
|
||||||
|
selected.append(paths[index])
|
||||||
|
return selected
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_hop_gate_decision(decision: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in decision.items()
|
||||||
|
if key != "expand_path_indexes"
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_assertion_records(
|
||||||
|
assertion_records: Iterable[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for record in assertion_records or []:
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
continue
|
||||||
|
assertion_id = _text(record.get("assertion_id"))
|
||||||
|
if not assertion_id.startswith("cass_") or assertion_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(assertion_id)
|
||||||
|
output.append(dict(record))
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _load_verified_first_hops(
|
||||||
|
self, records: List[Dict[str, Any]]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
statement = Query(
|
||||||
|
"""
|
||||||
|
UNWIND $records AS seed
|
||||||
|
MATCH (a:SRT2CanonicalAssertion {assertion_id: seed.assertion_id})
|
||||||
|
MATCH (s:SRT2CanonicalEntity {canonical_id: a.source_canonical_id})
|
||||||
|
MATCH (t:SRT2CanonicalEntity {canonical_id: a.target_canonical_id})
|
||||||
|
MATCH (s)-[r:SRT2_CANONICAL_ASSERTS]-(t)
|
||||||
|
WHERE r.assertion_id = a.assertion_id
|
||||||
|
RETURN seed.rank_after_bge AS seed_rank,
|
||||||
|
seed.final_score AS seed_final_score,
|
||||||
|
seed.rerank_score AS seed_rerank_score,
|
||||||
|
seed.vector_score AS seed_vector_score,
|
||||||
|
a.assertion_id AS assertion_id,
|
||||||
|
s.canonical_id AS from_id,
|
||||||
|
s.name AS from_name,
|
||||||
|
t.canonical_id AS to_id,
|
||||||
|
t.name AS to_name,
|
||||||
|
a.source AS source,
|
||||||
|
a.source_type AS source_type,
|
||||||
|
a.source_canonical_id AS source_canonical_id,
|
||||||
|
a.relation_norm AS relation,
|
||||||
|
a.relation_category AS relation_category,
|
||||||
|
a.target AS target,
|
||||||
|
a.target_type AS target_type,
|
||||||
|
a.target_canonical_id AS target_canonical_id,
|
||||||
|
a.best_evidence AS evidence,
|
||||||
|
a.best_paper_id AS paper_id,
|
||||||
|
a.title AS title,
|
||||||
|
a.pmid AS pmid,
|
||||||
|
a.pmcid AS pmcid,
|
||||||
|
a.doi AS doi,
|
||||||
|
'' AS species,
|
||||||
|
coalesce(a.support_count, 0) AS support_count,
|
||||||
|
coalesce(a.paper_count, 0) AS paper_count,
|
||||||
|
coalesce(a.best_score, 0.0) AS best_score,
|
||||||
|
1 AS category_match,
|
||||||
|
0 AS relation_clue_score
|
||||||
|
ORDER BY seed.rank_after_bge ASC
|
||||||
|
""",
|
||||||
|
timeout=self._bounded_neo4j_timeout,
|
||||||
|
)
|
||||||
|
with self.driver.session() as session:
|
||||||
|
return [dict(record) for record in session.run(statement, records=records)]
|
||||||
|
|
||||||
|
def _score_anchored_path(
|
||||||
|
self,
|
||||||
|
path: GraphPath,
|
||||||
|
question_norm: str,
|
||||||
|
seed_norms: set[str],
|
||||||
|
allowed: set[str],
|
||||||
|
relation_clues: List[str],
|
||||||
|
answer_types: List[str],
|
||||||
|
entity_clues: List[str],
|
||||||
|
vector_score: float,
|
||||||
|
) -> Tuple[float, Dict[str, Any]]:
|
||||||
|
base_score, breakdown = self._score_path(
|
||||||
|
path,
|
||||||
|
question_norm,
|
||||||
|
seed_norms,
|
||||||
|
allowed,
|
||||||
|
relation_clues,
|
||||||
|
answer_types,
|
||||||
|
entity_clues,
|
||||||
|
)
|
||||||
|
vector_score = max(0.0, min(float(vector_score or 0.0), 1.0))
|
||||||
|
multihop_bonus = 0.035 if len(path.hops) >= 2 else 0.0
|
||||||
|
terminal_relation_tokens = {
|
||||||
|
token
|
||||||
|
for token in _norm(path.hops[-1].relation if path.hops else "").split()
|
||||||
|
if len(token) >= 4
|
||||||
|
}
|
||||||
|
terminal_relation_overlap = (
|
||||||
|
len(terminal_relation_tokens & set(question_norm.split()))
|
||||||
|
/ max(1, len(terminal_relation_tokens))
|
||||||
|
)
|
||||||
|
final_score = min(
|
||||||
|
1.0,
|
||||||
|
0.68 * base_score
|
||||||
|
+ 0.32 * vector_score
|
||||||
|
+ multihop_bonus
|
||||||
|
+ 0.18 * terminal_relation_overlap,
|
||||||
|
)
|
||||||
|
breakdown.update(
|
||||||
|
{
|
||||||
|
"base_native_path_score": base_score,
|
||||||
|
"vector_first_hop_score": vector_score,
|
||||||
|
"vector_first_hop_weight": 0.32,
|
||||||
|
"first_hop_locked": True,
|
||||||
|
"multihop_bonus": multihop_bonus,
|
||||||
|
"terminal_relation_question_overlap": terminal_relation_overlap,
|
||||||
|
"final_score": final_score,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return final_score, breakdown
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_hop(
|
||||||
|
row: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
hop_index: int,
|
||||||
|
edge_score: float,
|
||||||
|
edge_breakdown: Dict[str, Any],
|
||||||
|
) -> GraphHop:
|
||||||
|
return GraphHop(
|
||||||
|
hop_index=hop_index,
|
||||||
|
from_id=_text(row.get("from_id")),
|
||||||
|
from_name=_text(row.get("from_name")),
|
||||||
|
to_id=_text(row.get("to_id")),
|
||||||
|
to_name=_text(row.get("to_name")),
|
||||||
|
source=_text(row.get("source")),
|
||||||
|
relation=_text(row.get("relation")),
|
||||||
|
target=_text(row.get("target")),
|
||||||
|
source_canonical_id=_text(row.get("source_canonical_id")),
|
||||||
|
target_canonical_id=_text(row.get("target_canonical_id")),
|
||||||
|
source_type=_text(row.get("source_type")),
|
||||||
|
target_type=_text(row.get("target_type")),
|
||||||
|
relation_category=_text(row.get("relation_category")),
|
||||||
|
assertion_id=_text(row.get("assertion_id")),
|
||||||
|
paper_id=_text(row.get("paper_id")),
|
||||||
|
title=_text(row.get("title")),
|
||||||
|
pmid=_text(row.get("pmid")),
|
||||||
|
pmcid=_text(row.get("pmcid")),
|
||||||
|
doi=_text(row.get("doi")),
|
||||||
|
species=_text(row.get("species")),
|
||||||
|
evidence=_text(row.get("evidence")),
|
||||||
|
support_count=int(row.get("support_count") or 0),
|
||||||
|
paper_count=int(row.get("paper_count") or 0),
|
||||||
|
best_score=float(row.get("best_score") or 0.0),
|
||||||
|
edge_score=edge_score,
|
||||||
|
score_breakdown=edge_breakdown,
|
||||||
|
passive_relation=False,
|
||||||
|
structural_direction=(
|
||||||
|
f"{_text(row.get('source'))} -> {_text(row.get('target'))}"
|
||||||
|
),
|
||||||
|
semantic_direction=(
|
||||||
|
f"{_text(row.get('source'))} -> {_text(row.get('target'))}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _select_diverse_paths(
|
||||||
|
paths: List[GraphPath], top_k: int
|
||||||
|
) -> List[GraphPath]:
|
||||||
|
ranked = sorted(
|
||||||
|
paths,
|
||||||
|
key=lambda path: (len(path.hops) >= 2, path.path_score),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
selected: List[GraphPath] = []
|
||||||
|
selected_keys: set[Tuple[str, ...]] = set()
|
||||||
|
first_hop_counts: Dict[str, int] = {}
|
||||||
|
for path in ranked:
|
||||||
|
key = tuple(path.assertion_ids())
|
||||||
|
if not key or key in selected_keys:
|
||||||
|
continue
|
||||||
|
first = key[0]
|
||||||
|
if first_hop_counts.get(first, 0) >= 2:
|
||||||
|
continue
|
||||||
|
selected.append(path)
|
||||||
|
selected_keys.add(key)
|
||||||
|
first_hop_counts[first] = first_hop_counts.get(first, 0) + 1
|
||||||
|
if len(selected) >= top_k:
|
||||||
|
return selected
|
||||||
|
for path in ranked:
|
||||||
|
key = tuple(path.assertion_ids())
|
||||||
|
if not key or key in selected_keys:
|
||||||
|
continue
|
||||||
|
selected.append(path)
|
||||||
|
selected_keys.add(key)
|
||||||
|
if len(selected) >= top_k:
|
||||||
|
break
|
||||||
|
return selected
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
class AuditWriter:
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self.root = root
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def new_query_id(self) -> str:
|
||||||
|
return time.strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
def write(self, query_id: str, payload: Dict[str, Any]) -> Path:
|
||||||
|
path = self.root / query_id
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = path / "qa_audit.json"
|
||||||
|
with self._lock:
|
||||||
|
target.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2, default=str),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return target
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from neo4j import GraphDatabase
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_QDRANT_COLLECTIONS = {
|
||||||
|
"plant_srt_direct_v1_bge_m3_20260702",
|
||||||
|
"plant_srt_direct_v1_bge_m3_citations_20260717",
|
||||||
|
}
|
||||||
|
REQUIRED_NODE_LABELS = {
|
||||||
|
"SRT2Paper",
|
||||||
|
"SRT2CanonicalEntity",
|
||||||
|
"SRT2CanonicalAssertion",
|
||||||
|
}
|
||||||
|
REQUIRED_RELATIONSHIP_TYPES = {
|
||||||
|
"SRT2_CANONICAL_ASSERTS",
|
||||||
|
"SRT2_SUPPORTED_BY",
|
||||||
|
}
|
||||||
|
FORBIDDEN_NODE_LABELS = {
|
||||||
|
"Entity",
|
||||||
|
"PCEntity",
|
||||||
|
"PCRelationEvidence",
|
||||||
|
"PCPaper",
|
||||||
|
"SRT2Entity",
|
||||||
|
"SRT2Assertion",
|
||||||
|
}
|
||||||
|
FORBIDDEN_RELATIONSHIP_TYPES = {
|
||||||
|
"RELATION",
|
||||||
|
"PC_REL",
|
||||||
|
"HAS_SOURCE",
|
||||||
|
"HAS_TARGET",
|
||||||
|
"FROM_PAPER",
|
||||||
|
"SRT2_ASSERTS",
|
||||||
|
"SRT2_ASSERTION_SOURCE",
|
||||||
|
"SRT2_ASSERTION_TARGET",
|
||||||
|
}
|
||||||
|
EXPECTED_NEO4J_PAPERS = 31_207
|
||||||
|
EXPECTED_NEO4J_CANONICAL_ENTITIES = 3_820_996
|
||||||
|
EXPECTED_NEO4J_CANONICAL_ASSERTIONS = 8_246_875
|
||||||
|
EXPECTED_NEO4J_CANONICAL_ASSERTS = 8_246_875
|
||||||
|
EXPECTED_NEO4J_SUPPORTED_BY = 8_202_284
|
||||||
|
|
||||||
|
|
||||||
|
class BackendIdentityError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _qdrant_json(url: str) -> Dict[str, Any]:
|
||||||
|
with urlopen(url, timeout=30) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
|
||||||
|
def _qdrant_sample(url: str, collection: str) -> Dict[str, Any]:
|
||||||
|
body = json.dumps(
|
||||||
|
{"limit": 1, "with_payload": True, "with_vector": False}
|
||||||
|
).encode("utf-8")
|
||||||
|
request = Request(
|
||||||
|
f"{url.rstrip('/')}/collections/{collection}/points/scroll",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
data = json.load(response)
|
||||||
|
points = data.get("result", {}).get("points") or []
|
||||||
|
return dict(points[0].get("payload") or {}) if points else {}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_backends() -> Dict[str, Any]:
|
||||||
|
graph_schema = os.environ.get("QA_V1_GRAPH_SCHEMA", "canonical").strip().lower()
|
||||||
|
if graph_schema != "canonical":
|
||||||
|
raise BackendIdentityError(
|
||||||
|
f"QA_V1_GRAPH_SCHEMA must be canonical, got {graph_schema!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
collection = os.environ.get(
|
||||||
|
"V15_QDRANT_COLLECTION", "plant_srt_direct_v1_bge_m3_20260702"
|
||||||
|
).strip()
|
||||||
|
if collection not in ALLOWED_QDRANT_COLLECTIONS:
|
||||||
|
raise BackendIdentityError(
|
||||||
|
f"Qdrant collection {collection!r} is not in the production allowlist"
|
||||||
|
)
|
||||||
|
|
||||||
|
qdrant_url = os.environ.get(
|
||||||
|
"V15_QDRANT_URL", os.environ.get("QDRANT_URL", "http://127.0.0.1:6333")
|
||||||
|
).rstrip("/")
|
||||||
|
qdrant_info = _qdrant_json(f"{qdrant_url}/collections/{collection}").get(
|
||||||
|
"result", {}
|
||||||
|
)
|
||||||
|
qdrant_points = int(qdrant_info.get("points_count") or 0)
|
||||||
|
if qdrant_points != 8_246_777:
|
||||||
|
raise BackendIdentityError(
|
||||||
|
f"Qdrant identity mismatch: expected 8246777 points, got {qdrant_points}"
|
||||||
|
)
|
||||||
|
sample_payload = _qdrant_sample(qdrant_url, collection)
|
||||||
|
if sample_payload.get("source_system") != "V15_V16_canonical":
|
||||||
|
raise BackendIdentityError(
|
||||||
|
"Qdrant payload is not the V15/V16 canonical assertion collection"
|
||||||
|
)
|
||||||
|
|
||||||
|
neo4j_uri = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:18788")
|
||||||
|
neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
|
||||||
|
neo4j_password = os.environ.get("NEO4J_PASSWORD", "")
|
||||||
|
driver = GraphDatabase.driver(
|
||||||
|
neo4j_uri,
|
||||||
|
auth=(neo4j_user, neo4j_password),
|
||||||
|
connection_timeout=15,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with driver.session() as session:
|
||||||
|
labels = set(
|
||||||
|
session.run(
|
||||||
|
"CALL db.labels() YIELD label RETURN label"
|
||||||
|
).value("label")
|
||||||
|
)
|
||||||
|
relationship_types = set(
|
||||||
|
session.run(
|
||||||
|
"CALL db.relationshipTypes() YIELD relationshipType "
|
||||||
|
"RETURN relationshipType"
|
||||||
|
).value("relationshipType")
|
||||||
|
)
|
||||||
|
counts = session.run(
|
||||||
|
"""
|
||||||
|
MATCH (p:SRT2Paper)
|
||||||
|
WITH count(p) AS papers
|
||||||
|
MATCH (c:SRT2CanonicalAssertion)
|
||||||
|
WITH papers, count(c) AS canonical_assertions
|
||||||
|
MATCH (e:SRT2CanonicalEntity)
|
||||||
|
RETURN papers, canonical_assertions,
|
||||||
|
count(e) AS canonical_entities
|
||||||
|
"""
|
||||||
|
).single()
|
||||||
|
relationship_counts = {
|
||||||
|
row["relationship_type"]: int(row["count"])
|
||||||
|
for row in session.run(
|
||||||
|
"""
|
||||||
|
CALL db.relationshipTypes() YIELD relationshipType
|
||||||
|
CALL {
|
||||||
|
WITH relationshipType
|
||||||
|
MATCH ()-[r]->()
|
||||||
|
WHERE type(r) = relationshipType
|
||||||
|
RETURN count(r) AS relation_count
|
||||||
|
}
|
||||||
|
RETURN relationshipType AS relationship_type,
|
||||||
|
relation_count AS count
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
driver.close()
|
||||||
|
|
||||||
|
missing_labels = REQUIRED_NODE_LABELS - labels
|
||||||
|
forbidden_labels = FORBIDDEN_NODE_LABELS & labels
|
||||||
|
missing_relationships = REQUIRED_RELATIONSHIP_TYPES - relationship_types
|
||||||
|
forbidden_relationships = FORBIDDEN_RELATIONSHIP_TYPES & relationship_types
|
||||||
|
if missing_labels or forbidden_labels or missing_relationships or forbidden_relationships:
|
||||||
|
raise BackendIdentityError(
|
||||||
|
"Neo4j schema identity mismatch: "
|
||||||
|
f"missing_labels={sorted(missing_labels)}, "
|
||||||
|
f"forbidden_labels={sorted(forbidden_labels)}, "
|
||||||
|
f"missing_relationships={sorted(missing_relationships)}, "
|
||||||
|
f"forbidden_relationships={sorted(forbidden_relationships)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
papers = int(counts["papers"])
|
||||||
|
canonical_entities = int(counts["canonical_entities"])
|
||||||
|
canonical_assertions = int(counts["canonical_assertions"])
|
||||||
|
canonical_asserts = relationship_counts.get("SRT2_CANONICAL_ASSERTS", 0)
|
||||||
|
supported_by = relationship_counts.get("SRT2_SUPPORTED_BY", 0)
|
||||||
|
expected_identity = (
|
||||||
|
papers == EXPECTED_NEO4J_PAPERS
|
||||||
|
and canonical_entities == EXPECTED_NEO4J_CANONICAL_ENTITIES
|
||||||
|
and canonical_assertions == EXPECTED_NEO4J_CANONICAL_ASSERTIONS
|
||||||
|
and canonical_asserts == EXPECTED_NEO4J_CANONICAL_ASSERTS
|
||||||
|
and supported_by == EXPECTED_NEO4J_SUPPORTED_BY
|
||||||
|
)
|
||||||
|
if not expected_identity:
|
||||||
|
raise BackendIdentityError(
|
||||||
|
"Neo4j store does not match the July 2 direct canonical identity: "
|
||||||
|
f"papers={papers}, canonical_entities={canonical_entities}, "
|
||||||
|
f"canonical_assertions={canonical_assertions}, "
|
||||||
|
f"canonical_asserts={canonical_asserts}, supported_by={supported_by}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"backend_policy": "neo4j_qdrant_only",
|
||||||
|
"graph_schema": graph_schema,
|
||||||
|
"neo4j_uri": neo4j_uri,
|
||||||
|
"neo4j_store": "neo4j_plant_srt_direct_v1_20260702",
|
||||||
|
"neo4j_papers": papers,
|
||||||
|
"neo4j_canonical_entities": canonical_entities,
|
||||||
|
"neo4j_canonical_assertions": canonical_assertions,
|
||||||
|
"neo4j_canonical_asserts": canonical_asserts,
|
||||||
|
"neo4j_supported_by": supported_by,
|
||||||
|
"qdrant_url": qdrant_url,
|
||||||
|
"qdrant_collection": collection,
|
||||||
|
"qdrant_points": qdrant_points,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(json.dumps(validate_backends(), ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from neo4j import Query
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models
|
||||||
|
|
||||||
|
from .direct_flow_retriever import DirectFlowRetriever, _norm, _text
|
||||||
|
from .graph_path_chain import NativeGraphPathRetriever
|
||||||
|
from .schemas import EvidenceItem, RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class _BoundedCanonicalMixin:
|
||||||
|
"""Generate bounded graph candidates from indexed Qdrant payload fields.
|
||||||
|
|
||||||
|
Neo4j is used only to validate exact cass_* assertion IDs. This avoids
|
||||||
|
enumerating and sorting every relationship attached to a high-degree node.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _bounded_init(self) -> None:
|
||||||
|
self._bounded_qdrant = QdrantClient(
|
||||||
|
url=os.environ.get(
|
||||||
|
"V15_QDRANT_URL",
|
||||||
|
os.environ.get("QDRANT_URL", "http://127.0.0.1:6333"),
|
||||||
|
),
|
||||||
|
timeout=float(os.environ.get("QA_V1_BOUNDED_QDRANT_TIMEOUT", "20")),
|
||||||
|
)
|
||||||
|
self._bounded_collection = os.environ.get(
|
||||||
|
"V15_QDRANT_COLLECTION",
|
||||||
|
"plant_srt_direct_v1_bge_m3_20260702",
|
||||||
|
)
|
||||||
|
self._bounded_embed_url = os.environ.get(
|
||||||
|
"QA_V112_EMBED_URL",
|
||||||
|
os.environ.get("QA_V1_EMBED_URL", "http://127.0.0.1:11434/api/embed"),
|
||||||
|
)
|
||||||
|
self._bounded_embed_model = os.environ.get(
|
||||||
|
"QA_V112_EMBED_MODEL",
|
||||||
|
os.environ.get("QA_V1_EMBED_MODEL", "bge-m3:567m"),
|
||||||
|
)
|
||||||
|
self._bounded_max_frontier = max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_BOUNDED_MAX_FRONTIER", "24")), 48)
|
||||||
|
)
|
||||||
|
self._bounded_per_node_cap = max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_BOUNDED_PER_NODE", "8")), 16)
|
||||||
|
)
|
||||||
|
self._bounded_max_hops = max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_BOUNDED_MAX_HOPS", "2")), 3)
|
||||||
|
)
|
||||||
|
self._bounded_neo4j_timeout = max(
|
||||||
|
1.0,
|
||||||
|
min(
|
||||||
|
float(os.environ.get("QA_V1_BOUNDED_NEO4J_TIMEOUT", "5")),
|
||||||
|
20.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._bounded_question = ""
|
||||||
|
self._bounded_vector: List[float] = []
|
||||||
|
self._bounded_metrics: Dict[str, Any] = {}
|
||||||
|
self._bounded_reset_metrics()
|
||||||
|
|
||||||
|
def _bounded_reset_metrics(self) -> None:
|
||||||
|
self._bounded_metrics = {
|
||||||
|
"backend": "qdrant_payload_index_candidates_plus_neo4j_exact_assertion_validation",
|
||||||
|
"collection": getattr(self, "_bounded_collection", ""),
|
||||||
|
"max_frontier": getattr(self, "_bounded_max_frontier", 0),
|
||||||
|
"per_node_cap": getattr(self, "_bounded_per_node_cap", 0),
|
||||||
|
"max_hops": getattr(self, "_bounded_max_hops", 0),
|
||||||
|
"qdrant_batch_calls": 0,
|
||||||
|
"qdrant_requests": 0,
|
||||||
|
"qdrant_candidates": 0,
|
||||||
|
"neo4j_validation_calls": 0,
|
||||||
|
"neo4j_validated": 0,
|
||||||
|
"qdrant_seconds": 0.0,
|
||||||
|
"neo4j_seconds": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _bounded_prepare_question(self, question: str) -> None:
|
||||||
|
self._bounded_question = _text(question)
|
||||||
|
response = requests.post(
|
||||||
|
self._bounded_embed_url,
|
||||||
|
json={
|
||||||
|
"model": self._bounded_embed_model,
|
||||||
|
"input": self._bounded_question[:2400],
|
||||||
|
},
|
||||||
|
timeout=float(os.environ.get("QA_V1_BOUNDED_EMBED_TIMEOUT", "30")),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
vectors = payload.get("embeddings") or []
|
||||||
|
if vectors:
|
||||||
|
self._bounded_vector = list(vectors[0])
|
||||||
|
elif payload.get("embedding"):
|
||||||
|
self._bounded_vector = list(payload["embedding"])
|
||||||
|
else:
|
||||||
|
raise RuntimeError("bounded graph embedding endpoint returned no vector")
|
||||||
|
|
||||||
|
def _bounded_candidates(
|
||||||
|
self,
|
||||||
|
frontier_ids: Iterable[str],
|
||||||
|
per_node_expand: int,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
ids = []
|
||||||
|
seen = set()
|
||||||
|
for value in frontier_ids:
|
||||||
|
canonical_id = _text(value)
|
||||||
|
if (
|
||||||
|
canonical_id.startswith("cent_")
|
||||||
|
and canonical_id not in seen
|
||||||
|
and len(ids) < self._bounded_max_frontier
|
||||||
|
):
|
||||||
|
ids.append(canonical_id)
|
||||||
|
seen.add(canonical_id)
|
||||||
|
if not ids or not self._bounded_vector:
|
||||||
|
return []
|
||||||
|
|
||||||
|
per_node = max(
|
||||||
|
1,
|
||||||
|
min(
|
||||||
|
int(per_node_expand or self._bounded_per_node_cap),
|
||||||
|
self._bounded_per_node_cap,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
requests_batch = [
|
||||||
|
models.QueryRequest(
|
||||||
|
query=self._bounded_vector,
|
||||||
|
filter=models.Filter(
|
||||||
|
must=[
|
||||||
|
models.FieldCondition(
|
||||||
|
key="source_canonical_id",
|
||||||
|
match=models.MatchValue(value=canonical_id),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
limit=per_node,
|
||||||
|
with_payload=True,
|
||||||
|
with_vector=False,
|
||||||
|
)
|
||||||
|
for canonical_id in ids
|
||||||
|
]
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
responses = self._bounded_qdrant.query_batch_points(
|
||||||
|
collection_name=self._bounded_collection,
|
||||||
|
requests=requests_batch,
|
||||||
|
timeout=int(
|
||||||
|
float(os.environ.get("QA_V1_BOUNDED_QDRANT_TIMEOUT", "20"))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._bounded_metrics["qdrant_seconds"] += time.perf_counter() - started
|
||||||
|
self._bounded_metrics["qdrant_batch_calls"] += 1
|
||||||
|
self._bounded_metrics["qdrant_requests"] += len(requests_batch)
|
||||||
|
|
||||||
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
for canonical_id, response in zip(ids, responses):
|
||||||
|
for point in response.points:
|
||||||
|
payload = dict(point.payload or {})
|
||||||
|
if _text(payload.get("source_canonical_id")) != canonical_id:
|
||||||
|
continue
|
||||||
|
payload["_bounded_vector_score"] = float(point.score or 0.0)
|
||||||
|
candidates.append(payload)
|
||||||
|
self._bounded_metrics["qdrant_candidates"] += len(candidates)
|
||||||
|
return self._bounded_validate_assertions(candidates)
|
||||||
|
|
||||||
|
def _bounded_validate_assertions(
|
||||||
|
self, candidates: List[Dict[str, Any]]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
by_id: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in candidates:
|
||||||
|
assertion_id = _text(row.get("assertion_id") or row.get("relation_id"))
|
||||||
|
if assertion_id.startswith("cass_") and assertion_id not in by_id:
|
||||||
|
by_id[assertion_id] = row
|
||||||
|
if not by_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
statement = Query(
|
||||||
|
"""
|
||||||
|
UNWIND $assertion_ids AS assertion_id
|
||||||
|
MATCH (a:SRT2CanonicalAssertion {assertion_id: assertion_id})
|
||||||
|
RETURN a.assertion_id AS assertion_id
|
||||||
|
""",
|
||||||
|
timeout=self._bounded_neo4j_timeout,
|
||||||
|
)
|
||||||
|
started = time.perf_counter()
|
||||||
|
with self.driver.session() as session:
|
||||||
|
validated = {
|
||||||
|
_text(record["assertion_id"])
|
||||||
|
for record in session.run(
|
||||||
|
statement, assertion_ids=list(by_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self._bounded_metrics["neo4j_seconds"] += time.perf_counter() - started
|
||||||
|
self._bounded_metrics["neo4j_validation_calls"] += 1
|
||||||
|
self._bounded_metrics["neo4j_validated"] += len(validated)
|
||||||
|
return [by_id[assertion_id] for assertion_id in by_id if assertion_id in validated]
|
||||||
|
|
||||||
|
|
||||||
|
class BoundedCanonicalGraphPathRetriever(
|
||||||
|
_BoundedCanonicalMixin, NativeGraphPathRetriever
|
||||||
|
):
|
||||||
|
"""Native path retriever with bounded, indexed adjacency candidate lookup."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._bounded_init()
|
||||||
|
|
||||||
|
def retrieve_chains(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
seed_ids: Iterable[str],
|
||||||
|
seed_names: Iterable[str],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||||||
|
self._bounded_reset_metrics()
|
||||||
|
self._bounded_prepare_question(question)
|
||||||
|
kwargs["max_hops"] = min(
|
||||||
|
int(kwargs.get("max_hops") or self._bounded_max_hops),
|
||||||
|
self._bounded_max_hops,
|
||||||
|
)
|
||||||
|
kwargs["beam_width"] = min(
|
||||||
|
int(kwargs.get("beam_width") or self._bounded_max_frontier),
|
||||||
|
self._bounded_max_frontier,
|
||||||
|
)
|
||||||
|
kwargs["per_node_expand"] = min(
|
||||||
|
int(kwargs.get("per_node_expand") or self._bounded_per_node_cap),
|
||||||
|
self._bounded_per_node_cap,
|
||||||
|
)
|
||||||
|
chains, audit = super().retrieve_chains(
|
||||||
|
question,
|
||||||
|
hypothesis,
|
||||||
|
seed_ids,
|
||||||
|
seed_names,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
audit["bounded_graph_backend"] = dict(self._bounded_metrics)
|
||||||
|
return chains, audit
|
||||||
|
|
||||||
|
def _fetch_neighbors(
|
||||||
|
self,
|
||||||
|
frontier_ids: List[str],
|
||||||
|
allowed: set[str],
|
||||||
|
*,
|
||||||
|
relation_clues: List[str],
|
||||||
|
priority_relation_clues: List[str],
|
||||||
|
entity_clues: List[str],
|
||||||
|
answer_type_groups: List[str],
|
||||||
|
per_node_expand: int,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
candidates = self._bounded_candidates(frontier_ids, per_node_expand)
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
allowed_norm = {_text(value) for value in allowed}
|
||||||
|
relation_terms = [
|
||||||
|
_norm(value) for value in relation_clues if _norm(value)
|
||||||
|
]
|
||||||
|
priority_terms = [
|
||||||
|
_norm(value) for value in priority_relation_clues if _norm(value)
|
||||||
|
]
|
||||||
|
entity_terms = {_norm(value) for value in entity_clues if _norm(value)}
|
||||||
|
answer_terms = [
|
||||||
|
_norm(value) for value in answer_type_groups if _norm(value)
|
||||||
|
]
|
||||||
|
for payload in candidates:
|
||||||
|
compact = _norm(
|
||||||
|
" ".join(
|
||||||
|
[
|
||||||
|
_text(payload.get("source")),
|
||||||
|
_text(payload.get("relation_norm") or payload.get("relation")),
|
||||||
|
_text(payload.get("target")),
|
||||||
|
_text(payload.get("relation_category")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
evidence = _norm(
|
||||||
|
payload.get("best_evidence") or payload.get("evidence")
|
||||||
|
)
|
||||||
|
target_norm = _norm(payload.get("target"))
|
||||||
|
relation_score = 0
|
||||||
|
if any(term in compact for term in priority_terms):
|
||||||
|
relation_score = 4
|
||||||
|
elif any(term in compact for term in relation_terms):
|
||||||
|
relation_score = 2
|
||||||
|
elif any(term in evidence for term in relation_terms):
|
||||||
|
relation_score = 1
|
||||||
|
target_type = _norm(payload.get("target_type"))
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"from_id": payload.get("source_canonical_id"),
|
||||||
|
"from_name": payload.get("source"),
|
||||||
|
"to_id": payload.get("target_canonical_id"),
|
||||||
|
"to_name": payload.get("target"),
|
||||||
|
"to_entity_type": payload.get("target_type"),
|
||||||
|
"assertion_id": payload.get("assertion_id"),
|
||||||
|
"relation_category": payload.get("relation_category") or "",
|
||||||
|
"source": payload.get("source"),
|
||||||
|
"source_type": payload.get("source_type"),
|
||||||
|
"source_canonical_id": payload.get("source_canonical_id"),
|
||||||
|
"relation": payload.get("relation_norm")
|
||||||
|
or payload.get("relation"),
|
||||||
|
"target": payload.get("target"),
|
||||||
|
"target_type": payload.get("target_type"),
|
||||||
|
"target_canonical_id": payload.get("target_canonical_id"),
|
||||||
|
"evidence": payload.get("best_evidence")
|
||||||
|
or payload.get("evidence"),
|
||||||
|
"paper_id": payload.get("best_paper_id")
|
||||||
|
or payload.get("paper_id"),
|
||||||
|
"title": payload.get("title") or "",
|
||||||
|
"pmid": payload.get("pmid") or "",
|
||||||
|
"pmcid": payload.get("pmcid") or "",
|
||||||
|
"doi": payload.get("doi") or "",
|
||||||
|
"species": payload.get("species") or "",
|
||||||
|
"support_count": payload.get("support_count") or 0,
|
||||||
|
"paper_count": payload.get("paper_count") or 0,
|
||||||
|
"best_score": payload.get("best_score")
|
||||||
|
or payload.get("quality_score")
|
||||||
|
or 0.0,
|
||||||
|
"entity_clue_score": int(target_norm in entity_terms),
|
||||||
|
"answer_type_score": int(
|
||||||
|
any(term in target_type for term in answer_terms)
|
||||||
|
),
|
||||||
|
"relation_clue_score": relation_score,
|
||||||
|
"category_match": int(
|
||||||
|
not allowed_norm
|
||||||
|
or _text(payload.get("relation_category"))
|
||||||
|
in allowed_norm
|
||||||
|
),
|
||||||
|
"bounded_vector_score": payload.get(
|
||||||
|
"_bounded_vector_score", 0.0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class BoundedCanonicalDirectFlowRetriever(
|
||||||
|
_BoundedCanonicalMixin, DirectFlowRetriever
|
||||||
|
):
|
||||||
|
"""Direct retriever whose graph lane cannot scan complete node adjacency."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._bounded_init()
|
||||||
|
self._bounded_seen_assertions: set[str] = set()
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
queries: Dict[str, List[str]],
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Tuple[List[EvidenceItem], Dict[str, Any]]:
|
||||||
|
self._bounded_reset_metrics()
|
||||||
|
self._bounded_seen_assertions = set()
|
||||||
|
self._bounded_prepare_question(question)
|
||||||
|
items, audit = super().retrieve(
|
||||||
|
question,
|
||||||
|
queries,
|
||||||
|
hypothesis,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
audit["bounded_graph_backend"] = dict(self._bounded_metrics)
|
||||||
|
return items, audit
|
||||||
|
|
||||||
|
def neo4j_seed_search(
|
||||||
|
self,
|
||||||
|
canonical_ids: List[str],
|
||||||
|
names: List[str],
|
||||||
|
limit: int,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
) -> List[EvidenceItem]:
|
||||||
|
ids = [
|
||||||
|
value
|
||||||
|
for value in canonical_ids
|
||||||
|
if _text(value).startswith("cent_")
|
||||||
|
][: self._bounded_max_frontier]
|
||||||
|
if not ids and names:
|
||||||
|
statement = Query(
|
||||||
|
"""
|
||||||
|
UNWIND $names AS name
|
||||||
|
MATCH (e:SRT2CanonicalEntity {name_norm: name})
|
||||||
|
RETURN e.canonical_id AS canonical_id
|
||||||
|
LIMIT $limit
|
||||||
|
""",
|
||||||
|
timeout=self._bounded_neo4j_timeout,
|
||||||
|
)
|
||||||
|
with self.driver.session() as session:
|
||||||
|
ids = [
|
||||||
|
_text(record["canonical_id"])
|
||||||
|
for record in session.run(
|
||||||
|
statement,
|
||||||
|
names=[_norm(name) for name in names[:20]],
|
||||||
|
limit=self._bounded_max_frontier,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
candidates = self._bounded_candidates(
|
||||||
|
ids,
|
||||||
|
min(
|
||||||
|
self._bounded_per_node_cap,
|
||||||
|
max(1, int(limit or 1) // max(1, len(ids))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
output: List[EvidenceItem] = []
|
||||||
|
for payload in candidates:
|
||||||
|
assertion_id = _text(
|
||||||
|
payload.get("assertion_id") or payload.get("relation_id")
|
||||||
|
)
|
||||||
|
if assertion_id in self._bounded_seen_assertions:
|
||||||
|
continue
|
||||||
|
self._bounded_seen_assertions.add(assertion_id)
|
||||||
|
item = self.item_from_payload(payload, method, "bounded_canonical")
|
||||||
|
item.vector_score = float(payload.get("_bounded_vector_score") or 0.0)
|
||||||
|
item.rrf_score = max(item.rrf_score, item.vector_score)
|
||||||
|
output.append(item)
|
||||||
|
if len(output) >= min(int(limit or 1), 160):
|
||||||
|
break
|
||||||
|
return output
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_PMID = re.compile(r"^(?:PMID\s*[::]?\s*)?(\d{5,10})$", re.I)
|
||||||
|
_VISIBLE_PMID = re.compile(r"\[PMID\s*[::]\s*(\d{5,10})\]", re.I)
|
||||||
|
_PMCID = re.compile(r"^PMC\d+$", re.I)
|
||||||
|
_DOI = re.compile(r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$", re.I)
|
||||||
|
_VISIBLE_PMCID = re.compile(r"\[PMCID\s*[::]\s*(PMC\d+)\]", re.I)
|
||||||
|
_VISIBLE_DOI = re.compile(r"\[DOI\s*[::]\s*(10\.\d{4,9}/[-._;()/:A-Z0-9]+)\]", re.I)
|
||||||
|
_VISIBLE_PUBLIC = re.compile(
|
||||||
|
r"\[(PMID|PMCID|DOI)\s*[::]\s*([^\]]+)\]",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_GROUPED_PUBLIC = re.compile(
|
||||||
|
r"\[((?:PMID|PMCID|DOI)\s*[::]\s*[^\]\n]+)\]",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_GROUPED_PUBLIC_SEPARATOR = re.compile(
|
||||||
|
r"\s*[,;]\s*(?=(?:PMID|PMCID|DOI)\s*[::])",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_GROUPED_PUBLIC_PART = re.compile(
|
||||||
|
r"^\s*(PMID|PMCID|DOI)\s*[::]\s*(.+?)\s*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_CITATION_LIKE = re.compile(r"\[(PMID|PMCID|DOI)([^\]\n]*)(\]|$)", re.I)
|
||||||
|
_FULL_WIDTH_PUBLIC_CITATION = re.compile(
|
||||||
|
r"【(?:PMID|PMCID|DOI)\b[^】\n]*】",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_INTERNAL_LABELS = (
|
||||||
|
re.compile(r"\[(?:S\d+|G\d+\.H\d+)\]", re.I),
|
||||||
|
re.compile(r"\[Data\s*:\s*(?:Sources|GraphChains)[^\]]*\]", re.I),
|
||||||
|
re.compile(r"\bG\d+\.H\d+\b", re.I),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_pmid(value: Any) -> str:
|
||||||
|
match = _PMID.fullmatch(str(value or "").strip())
|
||||||
|
return match.group(1) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def public_citation(*, evidence_id: str, pmid: str, pmcid: str, doi: str) -> str:
|
||||||
|
normalized = normalize_pmid(pmid)
|
||||||
|
if normalized:
|
||||||
|
return f"[PMID: {normalized}]"
|
||||||
|
clean_pmcid = str(pmcid or "").strip().upper()
|
||||||
|
if _PMCID.fullmatch(clean_pmcid):
|
||||||
|
return f"[PMCID: {clean_pmcid}]"
|
||||||
|
clean_doi = str(doi or "").strip()
|
||||||
|
if _DOI.fullmatch(clean_doi):
|
||||||
|
return f"[DOI: {clean_doi}]"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def cited_pmids(answer: str) -> set[str]:
|
||||||
|
return set(_VISIBLE_PMID.findall(str(answer or "")))
|
||||||
|
|
||||||
|
|
||||||
|
def visible_public_citations(answer: str) -> list[str]:
|
||||||
|
citations: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for kind, raw_value in _VISIBLE_PUBLIC.findall(str(answer or "")):
|
||||||
|
kind = kind.upper()
|
||||||
|
value = str(raw_value or "").strip()
|
||||||
|
if kind == "PMID":
|
||||||
|
normalized = normalize_pmid(value)
|
||||||
|
citation = f"[PMID: {normalized}]" if normalized else ""
|
||||||
|
elif kind == "PMCID":
|
||||||
|
normalized = value.upper()
|
||||||
|
citation = f"[PMCID: {normalized}]" if _PMCID.fullmatch(normalized) else ""
|
||||||
|
else:
|
||||||
|
citation = f"[DOI: {value}]" if _DOI.fullmatch(value) else ""
|
||||||
|
if citation and citation not in seen:
|
||||||
|
citations.append(citation)
|
||||||
|
seen.add(citation)
|
||||||
|
return citations
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_grouped_public_citations(
|
||||||
|
answer: str,
|
||||||
|
allowed_citations: set[str],
|
||||||
|
) -> tuple[str, list[dict[str, object]]]:
|
||||||
|
"""Split a citation group only when every identifier is already allowlisted."""
|
||||||
|
|
||||||
|
allowed = set(allowed_citations)
|
||||||
|
audit: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
original = match.group(0)
|
||||||
|
parts = _GROUPED_PUBLIC_SEPARATOR.split(match.group(1))
|
||||||
|
if len(parts) < 2:
|
||||||
|
return original
|
||||||
|
|
||||||
|
normalized: list[str] = []
|
||||||
|
for part in parts:
|
||||||
|
parsed = _GROUPED_PUBLIC_PART.fullmatch(part)
|
||||||
|
if not parsed:
|
||||||
|
return original
|
||||||
|
kind, value = parsed.groups()
|
||||||
|
citations = visible_public_citations(
|
||||||
|
f"[{kind.upper()}: {str(value or '').strip()}]"
|
||||||
|
)
|
||||||
|
if len(citations) != 1 or citations[0] not in allowed:
|
||||||
|
return original
|
||||||
|
if citations[0] not in normalized:
|
||||||
|
normalized.append(citations[0])
|
||||||
|
|
||||||
|
replacement = " ".join(normalized)
|
||||||
|
audit.append(
|
||||||
|
{
|
||||||
|
"original": original,
|
||||||
|
"replacement": replacement,
|
||||||
|
"citation_count": len(normalized),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return replacement
|
||||||
|
|
||||||
|
normalized_answer = _GROUPED_PUBLIC.sub(replace, str(answer or ""))
|
||||||
|
return normalized_answer, audit
|
||||||
|
|
||||||
|
|
||||||
|
def internal_answer_labels(answer: str) -> list[str]:
|
||||||
|
text = str(answer or "")
|
||||||
|
return sorted({match.group(0) for pattern in _INTERNAL_LABELS for match in pattern.finditer(text)})
|
||||||
|
|
||||||
|
|
||||||
|
def malformed_citation_labels(answer: str) -> list[str]:
|
||||||
|
malformed: set[str] = set()
|
||||||
|
text = str(answer or "")
|
||||||
|
malformed.update(
|
||||||
|
match.group(0) for match in _FULL_WIDTH_PUBLIC_CITATION.finditer(text)
|
||||||
|
)
|
||||||
|
for match in _CITATION_LIKE.finditer(text):
|
||||||
|
kind = match.group(1).upper()
|
||||||
|
tail = match.group(2).strip()
|
||||||
|
closing = match.group(3)
|
||||||
|
if closing != "]":
|
||||||
|
malformed.add(match.group(0))
|
||||||
|
continue
|
||||||
|
value = re.sub(r"^[::]\s*", "", tail)
|
||||||
|
if kind == "PMID":
|
||||||
|
identifier_shaped = bool(re.fullmatch(r"\d{5,10}", value))
|
||||||
|
elif kind == "PMCID":
|
||||||
|
identifier_shaped = bool(re.fullmatch(r"PMC\d+", value, re.I))
|
||||||
|
else:
|
||||||
|
identifier_shaped = bool(re.fullmatch(r"10\.\d{4,9}/\S+", value, re.I))
|
||||||
|
has_colon = bool(re.match(r"^[::]", tail))
|
||||||
|
if not identifier_shaped or not has_colon:
|
||||||
|
malformed.add(match.group(0))
|
||||||
|
return sorted(malformed)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_answer_citations(answer: str, allowed_citations: set[str]) -> dict[str, object]:
|
||||||
|
cited = visible_public_citations(answer)
|
||||||
|
allowed = set(allowed_citations)
|
||||||
|
unknown = [citation for citation in cited if citation not in allowed]
|
||||||
|
invalid_public: list[str] = []
|
||||||
|
for kind, value in _VISIBLE_PUBLIC.findall(str(answer or "")):
|
||||||
|
raw_citation = f"[{kind.upper()}: {str(value or '').strip()}]"
|
||||||
|
if not visible_public_citations(raw_citation):
|
||||||
|
invalid_public.append(raw_citation)
|
||||||
|
internal = internal_answer_labels(answer)
|
||||||
|
malformed = malformed_citation_labels(answer)
|
||||||
|
cited_pmids_values = [match for match in _VISIBLE_PMID.findall(str(answer or ""))]
|
||||||
|
unknown_pmids = [value for value in cited_pmids_values if f"[PMID: {value}]" in unknown]
|
||||||
|
cited_pmcids = [value.upper() for value in _VISIBLE_PMCID.findall(str(answer or ""))]
|
||||||
|
unknown_pmcids = [value for value in cited_pmcids if f"[PMCID: {value}]" in unknown]
|
||||||
|
cited_dois = list(_VISIBLE_DOI.findall(str(answer or "")))
|
||||||
|
unknown_dois = [value for value in cited_dois if f"[DOI: {value}]" in unknown]
|
||||||
|
return {
|
||||||
|
"valid": not unknown and not invalid_public and not malformed and not internal,
|
||||||
|
"cited_pmids": sorted(set(cited_pmids_values)),
|
||||||
|
"unknown_pmids": sorted(set(unknown_pmids)),
|
||||||
|
"cited_pmcids": sorted(set(cited_pmcids)),
|
||||||
|
"unknown_pmcids": sorted(set(unknown_pmcids)),
|
||||||
|
"cited_dois": sorted(set(cited_dois)),
|
||||||
|
"unknown_dois": sorted(set(unknown_dois)),
|
||||||
|
"invalid_public_citations": sorted(set(invalid_public)),
|
||||||
|
"malformed_citation_labels": malformed,
|
||||||
|
"internal_labels": internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_answer_pmids(answer: str, allowed_pmids: set[str]) -> dict[str, object]:
|
||||||
|
cited = cited_pmids(answer)
|
||||||
|
allowed = {value for value in (normalize_pmid(x) for x in allowed_pmids) if value}
|
||||||
|
unknown = sorted(cited - allowed)
|
||||||
|
return {
|
||||||
|
"valid": not unknown,
|
||||||
|
"cited_pmids": sorted(cited),
|
||||||
|
"unknown_pmids": unknown,
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .schemas import ClaimContext, EvidenceItem
|
||||||
|
|
||||||
|
|
||||||
|
_HTML_TAG = re.compile(r"<[^>]+>")
|
||||||
|
_BINOMIAL = r"[A-Z][a-z]{2,}\s+[a-z][a-z-]{2,}"
|
||||||
|
_NON_TAXON_WORDS = {
|
||||||
|
"development",
|
||||||
|
"factors",
|
||||||
|
"fruit",
|
||||||
|
"growth",
|
||||||
|
"hormones",
|
||||||
|
"plant",
|
||||||
|
"root",
|
||||||
|
"tomato",
|
||||||
|
"transcription",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_structured_species(value: str) -> bool:
|
||||||
|
if not re.fullmatch(_BINOMIAL, value):
|
||||||
|
return False
|
||||||
|
return not ({part.casefold() for part in value.split()} & _NON_TAXON_WORDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_species(text: str, structured_species: str) -> list[str]:
|
||||||
|
clean = _HTML_TAG.sub(" ", str(text or ""))
|
||||||
|
if _valid_structured_species(structured_species) and structured_species in clean:
|
||||||
|
return [structured_species]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _title_species(title: str) -> list[str]:
|
||||||
|
clean = _HTML_TAG.sub(" ", str(title or ""))
|
||||||
|
values: list[str] = []
|
||||||
|
for match in re.finditer(rf"\(({_BINOMIAL})(?:\s+[A-Z]\.)?\)", clean):
|
||||||
|
value = " ".join(match.group(1).split())
|
||||||
|
if _valid_structured_species(value) and value not in values:
|
||||||
|
values.append(value)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _first_raw_value(item: EvidenceItem, fields: Iterable[str]) -> str:
|
||||||
|
raw = item.raw if isinstance(item.raw, dict) else {}
|
||||||
|
for field in fields:
|
||||||
|
value = " ".join(str(raw.get(field) or "").split())
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_claim_context(item: EvidenceItem) -> ClaimContext:
|
||||||
|
evidence_text = str(item.evidence or "")
|
||||||
|
supplied_species = " ".join(str(item.species or "").split())
|
||||||
|
claim_species = _evidence_species(evidence_text, supplied_species)
|
||||||
|
paper_species = _title_species(item.title)
|
||||||
|
if (
|
||||||
|
_valid_structured_species(supplied_species)
|
||||||
|
and supplied_species in str(item.title or "")
|
||||||
|
and supplied_species not in paper_species
|
||||||
|
):
|
||||||
|
paper_species.append(supplied_species)
|
||||||
|
if claim_species:
|
||||||
|
species_source = "evidence"
|
||||||
|
confidence = 1.0
|
||||||
|
elif paper_species:
|
||||||
|
species_source = "title_only"
|
||||||
|
confidence = 0.5
|
||||||
|
else:
|
||||||
|
species_source = "none"
|
||||||
|
confidence = 0.0
|
||||||
|
|
||||||
|
return ClaimContext(
|
||||||
|
claim_species=claim_species,
|
||||||
|
paper_species=paper_species,
|
||||||
|
species_source=species_source,
|
||||||
|
subject=" ".join(str(item.source or "").split()),
|
||||||
|
function_or_process=" ".join(str(item.target or "").split()),
|
||||||
|
condition=_first_raw_value(item, ("condition", "treatment", "context")),
|
||||||
|
direction=" ".join(str(item.relation or "").split()),
|
||||||
|
confidence=confidence,
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from .evidence_pack import format_evidence_pack
|
||||||
|
from .llm_client import LLMClient
|
||||||
|
from .schemas import AtomicClaim, EvidenceItem
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimVerifier:
|
||||||
|
SYSTEM = """You verify claims from an unverified provisional plant-biology answer.
|
||||||
|
|
||||||
|
Use only supplied evidence. A claim may be supported, partially_supported,
|
||||||
|
unsupported, contradicted, context_mismatch, or not_retrieved. Do not treat
|
||||||
|
not_retrieved as false. Preserve species and experimental context. Return JSON
|
||||||
|
only."""
|
||||||
|
|
||||||
|
def __init__(self, llm: LLMClient):
|
||||||
|
self.llm = llm
|
||||||
|
|
||||||
|
def verify(
|
||||||
|
self, claims: List[AtomicClaim], evidence: List[EvidenceItem]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if not claims:
|
||||||
|
return []
|
||||||
|
payload = self.llm.complete_json(
|
||||||
|
self.SYSTEM,
|
||||||
|
f"""Claims:
|
||||||
|
{[{"claim_id": c.claim_id, "text": c.text} for c in claims]}
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
{format_evidence_pack(evidence)}
|
||||||
|
|
||||||
|
Return:
|
||||||
|
{{
|
||||||
|
"claims": [
|
||||||
|
{{
|
||||||
|
"claim_id":"H1",
|
||||||
|
"status":"supported|partially_supported|unsupported|contradicted|context_mismatch|not_retrieved",
|
||||||
|
"supporting_evidence_ids":["S1"],
|
||||||
|
"contradicting_evidence_ids":[],
|
||||||
|
"reason":""
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}""",
|
||||||
|
max_tokens=2600,
|
||||||
|
)
|
||||||
|
rows = payload.get("claims") if isinstance(payload, dict) else []
|
||||||
|
return rows if isinstance(rows, list) else []
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
from .schemas import EvidenceItem
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: Any) -> str:
|
||||||
|
text = str(value or "").lower()
|
||||||
|
text = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", text)
|
||||||
|
return " ".join(text.split())
|
||||||
|
|
||||||
|
|
||||||
|
def _cid(item: EvidenceItem, field: str, fallback: str) -> str:
|
||||||
|
raw = item.raw or {}
|
||||||
|
value = raw.get(field)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
return _norm(fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def relation_intent(item: EvidenceItem) -> str:
|
||||||
|
raw = item.raw or {}
|
||||||
|
rel = _norm(item.relation or raw.get("relation_norm"))
|
||||||
|
cat = _norm(raw.get("relation_category"))
|
||||||
|
text = f"{rel} {cat}"
|
||||||
|
|
||||||
|
if any(k in text for k in ["express", "upregulated", "downregulated", "accumulat", "induced"]):
|
||||||
|
return "expression"
|
||||||
|
if any(k in text for k in ["localiz", "located", "targeted_to", "subcellular"]):
|
||||||
|
return "localization"
|
||||||
|
if any(k in text for k in ["cis_element", "motif", "domain", "intron", "exon", "gene_structure", "structure"]):
|
||||||
|
return "structure"
|
||||||
|
if any(k in text for k in ["stress", "tolerance", "resistance", "pathogen", "defense", "salt", "drought", "cold", "heat"]):
|
||||||
|
return "stress_response"
|
||||||
|
if any(k in text for k in ["classification", "classified", "belongs", "member", "include", "contains", "comprise", "is_a", "clade"]):
|
||||||
|
return "classification"
|
||||||
|
if any(k in text for k in ["count", "catalog", "identified", "number"]):
|
||||||
|
return "catalog_count"
|
||||||
|
if any(k in text for k in ["ortholog", "homolog", "synten", "collinear"]):
|
||||||
|
return "orthology_homology"
|
||||||
|
if any(k in text for k in ["regulat", "promot", "inhibit", "affect", "function", "role", "involved", "participat"]):
|
||||||
|
return "function_effect"
|
||||||
|
return cat or rel or "other"
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_key(item: EvidenceItem) -> Tuple[str, str, str, str]:
|
||||||
|
source = _cid(item, "source_canonical_id", item.source)
|
||||||
|
target = _cid(item, "target_canonical_id", item.target)
|
||||||
|
intent = relation_intent(item)
|
||||||
|
layer = _norm(item.output_layer or (item.raw or {}).get("relation_category") or "")
|
||||||
|
return source, intent, target, layer
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_id_for(key: Tuple[str, str, str, str]) -> str:
|
||||||
|
digest = hashlib.sha1("|".join(key).encode("utf-8")).hexdigest()[:16]
|
||||||
|
return f"clu_{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _representative_score(item: EvidenceItem) -> Tuple[float, float, float, float]:
|
||||||
|
raw = item.raw or {}
|
||||||
|
has_evidence = 1.0 if item.evidence else 0.0
|
||||||
|
layer_score = 0.0
|
||||||
|
layer = _norm(item.output_layer)
|
||||||
|
if layer == "main_kg":
|
||||||
|
layer_score = 1.0
|
||||||
|
elif "background" in layer:
|
||||||
|
layer_score = 0.7
|
||||||
|
elif "candidate" in layer:
|
||||||
|
layer_score = 0.35
|
||||||
|
support = float(raw.get("support_count") or 0.0)
|
||||||
|
paper_count = float(raw.get("paper_count") or 0.0)
|
||||||
|
return (
|
||||||
|
has_evidence,
|
||||||
|
layer_score,
|
||||||
|
support + 0.25 * paper_count,
|
||||||
|
max(float(item.rrf_score or 0.0), float(item.vector_score or 0.0), float(item.final_score or 0.0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EvidenceCluster:
|
||||||
|
cluster_id: str
|
||||||
|
key: Tuple[str, str, str, str]
|
||||||
|
representative: EvidenceItem
|
||||||
|
members: List[EvidenceItem] = field(default_factory=list)
|
||||||
|
|
||||||
|
def finalize(self) -> EvidenceItem:
|
||||||
|
self.members.sort(key=_representative_score, reverse=True)
|
||||||
|
self.representative = self.members[0]
|
||||||
|
member_ids = [m.relation_id for m in self.members if m.relation_id]
|
||||||
|
pmids = []
|
||||||
|
paper_ids = []
|
||||||
|
for m in self.members:
|
||||||
|
if m.pmid and m.pmid not in pmids:
|
||||||
|
pmids.append(m.pmid)
|
||||||
|
if m.paper_id and m.paper_id not in paper_ids:
|
||||||
|
paper_ids.append(m.paper_id)
|
||||||
|
raw = dict(self.representative.raw or {})
|
||||||
|
raw["cluster_id"] = self.cluster_id
|
||||||
|
raw["cluster_key"] = list(self.key)
|
||||||
|
raw["cluster_member_assertion_ids"] = member_ids
|
||||||
|
raw["cluster_member_count"] = len(member_ids)
|
||||||
|
raw["cluster_member_paper_ids"] = paper_ids[:30]
|
||||||
|
raw["cluster_member_pmids"] = pmids[:30]
|
||||||
|
self.representative.raw = raw
|
||||||
|
self.representative.cluster_id = self.cluster_id
|
||||||
|
self.representative.cluster_member_count = len(member_ids)
|
||||||
|
self.representative.cluster_member_assertion_ids = member_ids
|
||||||
|
self.representative.cluster_member_paper_ids = paper_ids[:30]
|
||||||
|
self.representative.cluster_member_pmids = pmids[:30]
|
||||||
|
return self.representative
|
||||||
|
|
||||||
|
|
||||||
|
def build_clusters(items: Iterable[EvidenceItem]) -> Tuple[List[EvidenceItem], List[Dict[str, Any]]]:
|
||||||
|
clusters: Dict[Tuple[str, str, str, str], EvidenceCluster] = {}
|
||||||
|
for item in items:
|
||||||
|
key = cluster_key(item)
|
||||||
|
cid = cluster_id_for(key)
|
||||||
|
cluster = clusters.get(key)
|
||||||
|
if cluster is None:
|
||||||
|
cluster = EvidenceCluster(cluster_id=cid, key=key, representative=item)
|
||||||
|
clusters[key] = cluster
|
||||||
|
cluster.members.append(item)
|
||||||
|
|
||||||
|
representatives: List[EvidenceItem] = []
|
||||||
|
audit: List[Dict[str, Any]] = []
|
||||||
|
for cluster in clusters.values():
|
||||||
|
rep = cluster.finalize()
|
||||||
|
representatives.append(rep)
|
||||||
|
audit.append(
|
||||||
|
{
|
||||||
|
"cluster_id": cluster.cluster_id,
|
||||||
|
"cluster_key": list(cluster.key),
|
||||||
|
"representative_assertion_id": rep.relation_id,
|
||||||
|
"representative_srt": {
|
||||||
|
"source": rep.source,
|
||||||
|
"relation": rep.relation,
|
||||||
|
"target": rep.target,
|
||||||
|
},
|
||||||
|
"member_count": len(cluster.members),
|
||||||
|
"member_assertion_ids": [m.relation_id for m in cluster.members if m.relation_id],
|
||||||
|
"member_paper_ids": list(dict.fromkeys([m.paper_id for m in cluster.members if m.paper_id]))[:30],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
representatives.sort(key=_representative_score, reverse=True)
|
||||||
|
return representatives, audit
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QAConfig:
|
||||||
|
project_root: Path
|
||||||
|
gfanalysis_root: Path
|
||||||
|
alias_path: Path
|
||||||
|
taxonomy_path: Path
|
||||||
|
reranker_model: Path
|
||||||
|
initial_recall_per_query: int
|
||||||
|
max_query_variants: int
|
||||||
|
max_evidence: int
|
||||||
|
min_evidence: int
|
||||||
|
max_rounds: int
|
||||||
|
graph_hops: int
|
||||||
|
include_candidate: bool
|
||||||
|
enable_provisional_draft: bool
|
||||||
|
enable_second_round: bool
|
||||||
|
enable_bge_rerank: bool
|
||||||
|
graph_schema: str
|
||||||
|
enable_agentic_controller: bool = True
|
||||||
|
agentic_max_replans: int = 2
|
||||||
|
enable_question_decomposition: bool = True
|
||||||
|
subquestion_workers: int = 3
|
||||||
|
vector_query_workers: int = 2
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "QAConfig":
|
||||||
|
root = Path(os.environ.get("GENE_FAMILY_ROOT", "/data/gene-family-mas-v2"))
|
||||||
|
graph_schema = os.environ.get(
|
||||||
|
"QA_V1_GRAPH_SCHEMA", "canonical"
|
||||||
|
).strip().lower()
|
||||||
|
if graph_schema != "canonical":
|
||||||
|
raise RuntimeError(
|
||||||
|
"QA V1.1.73 production only permits the canonical graph schema"
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
project_root=root,
|
||||||
|
gfanalysis_root=Path(
|
||||||
|
os.environ.get("GFANALYSIS_ROOT", "/data/wutx/00test/gfAnalysis")
|
||||||
|
),
|
||||||
|
alias_path=Path(
|
||||||
|
os.environ.get(
|
||||||
|
"QA_V1_ALIAS_CONFIG",
|
||||||
|
root / "configs" / "rag" / "entity_alias.yaml",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
taxonomy_path=Path(
|
||||||
|
os.environ.get(
|
||||||
|
"QA_V1_TAXONOMY_CONFIG",
|
||||||
|
root / "configs" / "rag" / "relation_taxonomy.yaml",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
reranker_model=Path(
|
||||||
|
os.environ.get(
|
||||||
|
"QA_V1_BGE_RERANKER_MODEL",
|
||||||
|
root / "bge-reranker-base",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
initial_recall_per_query=int(
|
||||||
|
os.environ.get("QA_V1_INITIAL_RECALL_PER_QUERY", "50")
|
||||||
|
),
|
||||||
|
max_query_variants=int(
|
||||||
|
os.environ.get("QA_V1_MAX_QUERY_VARIANTS", "6")
|
||||||
|
),
|
||||||
|
max_evidence=int(os.environ.get("QA_V1_MAX_EVIDENCE", "40")),
|
||||||
|
min_evidence=int(os.environ.get("QA_V1_MIN_EVIDENCE", "8")),
|
||||||
|
max_rounds=max(1, min(int(os.environ.get("QA_V1_MAX_ROUNDS", "2")), 2)),
|
||||||
|
graph_hops=max(1, min(int(os.environ.get("QA_V1_GRAPH_HOPS", "2")), 2)),
|
||||||
|
include_candidate=os.environ.get("QA_V1_INCLUDE_CANDIDATE", "0") == "1",
|
||||||
|
enable_provisional_draft=os.environ.get(
|
||||||
|
"QA_V1_ENABLE_PROVISIONAL_DRAFT", "1"
|
||||||
|
)
|
||||||
|
== "1",
|
||||||
|
enable_second_round=os.environ.get("QA_V1_ENABLE_SECOND_ROUND", "1")
|
||||||
|
== "1",
|
||||||
|
enable_bge_rerank=os.environ.get("QA_V1_ENABLE_BGE_RERANK", "1")
|
||||||
|
== "1",
|
||||||
|
graph_schema=graph_schema,
|
||||||
|
enable_agentic_controller=os.environ.get(
|
||||||
|
"QA_V1_ENABLE_AGENTIC_CONTROLLER", "1"
|
||||||
|
)
|
||||||
|
== "1",
|
||||||
|
agentic_max_replans=max(
|
||||||
|
0, min(int(os.environ.get("QA_V1_AGENTIC_MAX_REPLANS", "2")), 2)
|
||||||
|
),
|
||||||
|
enable_question_decomposition=os.environ.get(
|
||||||
|
"QA_ENABLE_QUESTION_DECOMPOSITION", "1"
|
||||||
|
)
|
||||||
|
== "1",
|
||||||
|
subquestion_workers=max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_SUBQUESTION_WORKERS", "3")), 3)
|
||||||
|
),
|
||||||
|
vector_query_workers=max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_VECTOR_QUERY_WORKERS", "2")), 2)
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from neo4j import GraphDatabase
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
|
||||||
|
from .schemas import EvidenceItem, RetrievalHypothesis
|
||||||
|
|
||||||
|
ALLOWED_QDRANT_COLLECTIONS = {
|
||||||
|
"plant_srt_direct_v1_bge_m3_20260702",
|
||||||
|
"plant_srt_direct_v1_bge_m3_citations_20260717",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return " ".join(str(value or "").split())
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: Any) -> str:
|
||||||
|
import re
|
||||||
|
|
||||||
|
text = str(value or "").lower()
|
||||||
|
text = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", text)
|
||||||
|
return " ".join(text.split())
|
||||||
|
|
||||||
|
|
||||||
|
def load_paper_metadata_registry(path: Path) -> Dict[str, Dict[str, Any]]:
|
||||||
|
records: Dict[str, Dict[str, Any]] = {}
|
||||||
|
if not path.is_file():
|
||||||
|
return records
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
row = json.loads(line)
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
paper_id = _text(row.get("paper_id"))
|
||||||
|
if paper_id and paper_id not in records:
|
||||||
|
records[paper_id] = row
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
class DirectFlowRetriever:
|
||||||
|
"""Direct V15/V16 canonical Qdrant + Neo4j retriever.
|
||||||
|
|
||||||
|
This retriever deliberately avoids legacy SQLite/PC lanes and avoids graph
|
||||||
|
scans. Neo4j expansion starts only from canonical IDs or exact normalized
|
||||||
|
names. Qdrant and Neo4j share the cass_* assertion_id/relation_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
graph_schema: str | None = None,
|
||||||
|
vector_query_workers: int = 2,
|
||||||
|
) -> None:
|
||||||
|
self.graph_schema = (
|
||||||
|
graph_schema or os.environ.get("QA_V1_GRAPH_SCHEMA", "canonical")
|
||||||
|
).strip().lower()
|
||||||
|
if self.graph_schema != "canonical":
|
||||||
|
raise RuntimeError(
|
||||||
|
"Legacy Entity/RELATION and PCEntity/PC_REL graphs are disabled"
|
||||||
|
)
|
||||||
|
self.vector_query_workers = max(1, min(int(vector_query_workers), 2))
|
||||||
|
self.qdrant_url = os.environ.get("V15_QDRANT_URL", os.environ.get("QDRANT_URL", "http://127.0.0.1:6333"))
|
||||||
|
self.collection = os.environ.get("V15_QDRANT_COLLECTION", "plant_srt_direct_v1_bge_m3_20260702")
|
||||||
|
if self.collection not in ALLOWED_QDRANT_COLLECTIONS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Qdrant collection {self.collection!r} is not allowed in production"
|
||||||
|
)
|
||||||
|
self.embed_url = os.environ.get("QA_V112_EMBED_URL", os.environ.get("QA_V1_EMBED_URL", "http://127.0.0.1:11434/api/embed"))
|
||||||
|
self.embed_model = os.environ.get("QA_V112_EMBED_MODEL", os.environ.get("QA_V1_EMBED_MODEL", "bge-m3:567m"))
|
||||||
|
self.neo4j_uri = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:18788")
|
||||||
|
self.neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
|
||||||
|
self.neo4j_password = os.environ.get("NEO4J_PASSWORD", "plant_srt_direct_password")
|
||||||
|
registry_path = os.environ.get("QA_PAPER_METADATA_REGISTRY", "").strip()
|
||||||
|
self.paper_metadata = (
|
||||||
|
load_paper_metadata_registry(Path(registry_path)) if registry_path else {}
|
||||||
|
)
|
||||||
|
self.qdrant = QdrantClient(url=self.qdrant_url, timeout=60)
|
||||||
|
self.driver = GraphDatabase.driver(
|
||||||
|
self.neo4j_uri,
|
||||||
|
auth=(self.neo4j_user, self.neo4j_password),
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self.driver is not None:
|
||||||
|
self.driver.close()
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
queries: Dict[str, List[str]],
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
*,
|
||||||
|
qdrant_top_k: int = 120,
|
||||||
|
graph_top_k: int = 80,
|
||||||
|
use_qdrant: bool = True,
|
||||||
|
use_neo4j: bool = True,
|
||||||
|
use_graph_expansion: bool = True,
|
||||||
|
) -> Tuple[List[EvidenceItem], Dict[str, Any]]:
|
||||||
|
effective_use_neo4j = bool(use_neo4j) and self.graph_schema == "canonical"
|
||||||
|
effective_use_graph_expansion = (
|
||||||
|
bool(use_graph_expansion) and effective_use_neo4j
|
||||||
|
)
|
||||||
|
audit: Dict[str, Any] = {
|
||||||
|
"qdrant": [],
|
||||||
|
"seed_extraction": {},
|
||||||
|
"neo4j": [],
|
||||||
|
"dedup": {},
|
||||||
|
"options": {
|
||||||
|
"qdrant_top_k": qdrant_top_k,
|
||||||
|
"graph_top_k": graph_top_k,
|
||||||
|
"use_qdrant": use_qdrant,
|
||||||
|
"use_neo4j": use_neo4j,
|
||||||
|
"use_graph_expansion": use_graph_expansion,
|
||||||
|
"effective_use_neo4j": effective_use_neo4j,
|
||||||
|
"effective_use_graph_expansion": effective_use_graph_expansion,
|
||||||
|
},
|
||||||
|
"neo4j_status": (
|
||||||
|
"enabled"
|
||||||
|
if effective_use_neo4j
|
||||||
|
else "disabled_for_legacy_schema"
|
||||||
|
if self.graph_schema == "legacy"
|
||||||
|
else "disabled_by_request"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
all_queries = self._flatten_queries(question, queries)
|
||||||
|
qdrant_items: List[EvidenceItem] = []
|
||||||
|
if use_qdrant:
|
||||||
|
per_query = max(10, min(qdrant_top_k // max(1, len(all_queries)), 50))
|
||||||
|
if self.vector_query_workers == 1 or len(all_queries) == 1:
|
||||||
|
query_rows = [
|
||||||
|
(query, self.qdrant_search(query, per_query))
|
||||||
|
for query in all_queries
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
with ThreadPoolExecutor(
|
||||||
|
max_workers=min(self.vector_query_workers, len(all_queries)),
|
||||||
|
thread_name_prefix="qa-vector",
|
||||||
|
) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(self.qdrant_search, query, per_query)
|
||||||
|
for query in all_queries
|
||||||
|
]
|
||||||
|
query_rows = [
|
||||||
|
(query, future.result())
|
||||||
|
for query, future in zip(all_queries, futures)
|
||||||
|
]
|
||||||
|
for query, rows in query_rows:
|
||||||
|
audit["qdrant"].append({"query": query, "rows": len(rows)})
|
||||||
|
qdrant_items.extend(rows)
|
||||||
|
|
||||||
|
seed_ids, seed_names = self.extract_seeds(hypothesis, qdrant_items)
|
||||||
|
audit["seed_extraction"] = {
|
||||||
|
"canonical_ids": seed_ids[:50],
|
||||||
|
"names": seed_names[:50],
|
||||||
|
"canonical_id_count": len(seed_ids),
|
||||||
|
"name_count": len(seed_names),
|
||||||
|
}
|
||||||
|
|
||||||
|
graph_items: List[EvidenceItem] = []
|
||||||
|
if effective_use_neo4j:
|
||||||
|
direct_rows = self.neo4j_seed_search(seed_ids, seed_names, graph_top_k, method="neo4j_seed_exact")
|
||||||
|
audit["neo4j"].append({"stage": "seed_exact", "rows": len(direct_rows)})
|
||||||
|
graph_items.extend(direct_rows)
|
||||||
|
if effective_use_graph_expansion:
|
||||||
|
expansion_seed_ids = self._ids_from_items((qdrant_items[:30] + direct_rows[:30]))
|
||||||
|
expansion_rows = self.neo4j_seed_search(expansion_seed_ids, [], graph_top_k, method="neo4j_bounded_expand")
|
||||||
|
audit["neo4j"].append({"stage": "bounded_expand", "rows": len(expansion_rows)})
|
||||||
|
graph_items.extend(expansion_rows)
|
||||||
|
|
||||||
|
merged = self.dedup(qdrant_items + graph_items)
|
||||||
|
audit["dedup"] = {
|
||||||
|
"qdrant_items": len(qdrant_items),
|
||||||
|
"graph_items": len(graph_items),
|
||||||
|
"merged_items": len(merged),
|
||||||
|
}
|
||||||
|
return merged, audit
|
||||||
|
|
||||||
|
def qdrant_search(self, query: str, limit: int) -> List[EvidenceItem]:
|
||||||
|
try:
|
||||||
|
vector = self.embed(query)
|
||||||
|
response = self.qdrant.query_points(
|
||||||
|
collection_name=self.collection,
|
||||||
|
query=vector,
|
||||||
|
limit=limit,
|
||||||
|
with_payload=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
output: List[EvidenceItem] = []
|
||||||
|
for point in response.points:
|
||||||
|
payload = dict(point.payload or {})
|
||||||
|
item = self.item_from_payload(payload, "qdrant_vector", query)
|
||||||
|
item.vector_score = float(point.score or 0.0)
|
||||||
|
item.rrf_score = max(item.rrf_score, item.vector_score)
|
||||||
|
output.append(item)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def embed(self, text: str) -> List[float]:
|
||||||
|
response = requests.post(
|
||||||
|
self.embed_url,
|
||||||
|
json={"model": self.embed_model, "input": text[:2400]},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
embeddings = data.get("embeddings") or []
|
||||||
|
if embeddings:
|
||||||
|
return embeddings[0]
|
||||||
|
if "embedding" in data:
|
||||||
|
return data["embedding"]
|
||||||
|
raise RuntimeError("embedding endpoint returned no vector")
|
||||||
|
|
||||||
|
def neo4j_seed_search(
|
||||||
|
self,
|
||||||
|
canonical_ids: List[str],
|
||||||
|
names: List[str],
|
||||||
|
limit: int,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
) -> List[EvidenceItem]:
|
||||||
|
if not canonical_ids and not names:
|
||||||
|
return []
|
||||||
|
if self.driver is None:
|
||||||
|
raise RuntimeError("canonical Neo4j search is unavailable for legacy schema")
|
||||||
|
statement = """
|
||||||
|
MATCH (e:SRT2CanonicalEntity)
|
||||||
|
WHERE e.canonical_id IN $ids OR e.name_norm IN $names OR toLower(e.name) IN $names
|
||||||
|
WITH e LIMIT 30
|
||||||
|
MATCH (e)-[r:SRT2_CANONICAL_ASSERTS]-(n:SRT2CanonicalEntity)
|
||||||
|
MATCH (a:SRT2CanonicalAssertion {assertion_id: r.assertion_id})
|
||||||
|
RETURN a.assertion_id AS assertion_id,
|
||||||
|
a.source AS source,
|
||||||
|
a.source_type AS source_type,
|
||||||
|
a.source_canonical_id AS source_canonical_id,
|
||||||
|
a.relation_norm AS relation,
|
||||||
|
a.relation_category AS relation_category,
|
||||||
|
a.target AS target,
|
||||||
|
a.target_type AS target_type,
|
||||||
|
a.target_canonical_id AS target_canonical_id,
|
||||||
|
a.best_evidence AS evidence,
|
||||||
|
a.best_paper_id AS paper_id,
|
||||||
|
a.title AS title,
|
||||||
|
a.doi AS doi,
|
||||||
|
a.pmid AS pmid,
|
||||||
|
a.pmcid AS pmcid,
|
||||||
|
a.best_score AS score,
|
||||||
|
a.support_count AS support_count,
|
||||||
|
a.paper_count AS paper_count,
|
||||||
|
e.canonical_id AS matched_canonical_id,
|
||||||
|
e.name AS matched_canonical_name
|
||||||
|
ORDER BY coalesce(a.paper_count, 0) DESC,
|
||||||
|
coalesce(a.support_count, 0) DESC,
|
||||||
|
coalesce(a.best_score, 0.0) DESC
|
||||||
|
LIMIT $limit
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self.driver.session() as session:
|
||||||
|
rows = [
|
||||||
|
dict(record)
|
||||||
|
for record in session.run(
|
||||||
|
statement,
|
||||||
|
ids=canonical_ids[:80],
|
||||||
|
names=names[:80],
|
||||||
|
limit=min(limit, 160),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
output: List[EvidenceItem] = []
|
||||||
|
for row in rows:
|
||||||
|
payload = {
|
||||||
|
"assertion_id": row.get("assertion_id"),
|
||||||
|
"relation_id": row.get("assertion_id"),
|
||||||
|
"source": row.get("source"),
|
||||||
|
"source_type": row.get("source_type"),
|
||||||
|
"source_canonical_id": row.get("source_canonical_id"),
|
||||||
|
"relation": row.get("relation"),
|
||||||
|
"relation_norm": row.get("relation"),
|
||||||
|
"relation_category": row.get("relation_category"),
|
||||||
|
"target": row.get("target"),
|
||||||
|
"target_type": row.get("target_type"),
|
||||||
|
"target_canonical_id": row.get("target_canonical_id"),
|
||||||
|
"evidence": row.get("evidence"),
|
||||||
|
"paper_id": row.get("paper_id"),
|
||||||
|
"title": row.get("title"),
|
||||||
|
"doi": row.get("doi"),
|
||||||
|
"pmid": row.get("pmid"),
|
||||||
|
"pmcid": row.get("pmcid"),
|
||||||
|
"quality_score": row.get("score") or 0.0,
|
||||||
|
}
|
||||||
|
item = self.item_from_payload(payload, method, "canonical_seed")
|
||||||
|
item.vector_score = float(row.get("score") or 0.8)
|
||||||
|
item.rrf_score = max(float(row.get("paper_count") or 0) * 0.01, float(row.get("support_count") or 0) * 0.01, item.vector_score)
|
||||||
|
output.append(item)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def item_from_payload(self, payload: Dict[str, Any], method: str, query: str) -> EvidenceItem:
|
||||||
|
relation_id = _text(payload.get("relation_id") or payload.get("assertion_id"))
|
||||||
|
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||||
|
paper_id = _text(payload.get("paper_id") or payload.get("best_paper_id"))
|
||||||
|
registry_metadata = getattr(self, "paper_metadata", {}).get(paper_id, {})
|
||||||
|
return EvidenceItem(
|
||||||
|
evidence_id="",
|
||||||
|
kb="V16/V15",
|
||||||
|
source=_text(payload.get("source")),
|
||||||
|
relation=_text(payload.get("relation") or payload.get("relation_norm")),
|
||||||
|
target=_text(payload.get("target")),
|
||||||
|
evidence=_text(payload.get("evidence") or payload.get("best_evidence")),
|
||||||
|
paper_id=paper_id,
|
||||||
|
title=_text(payload.get("title") or payload.get("paper_title") or metadata.get("title") or registry_metadata.get("title")),
|
||||||
|
pmid=_text(payload.get("pmid") or payload.get("pubmed_id") or metadata.get("pmid") or registry_metadata.get("pmid")),
|
||||||
|
pmcid=_text(payload.get("pmcid") or metadata.get("pmcid") or registry_metadata.get("pmcid")),
|
||||||
|
doi=_text(payload.get("doi") or metadata.get("doi") or registry_metadata.get("doi")),
|
||||||
|
species=_text(payload.get("species") or metadata.get("species")),
|
||||||
|
section=_text(payload.get("section") or metadata.get("section")),
|
||||||
|
output_layer=_text(payload.get("output_layer") or "main_kg"),
|
||||||
|
claim_scope=_text(payload.get("claim_scope") or metadata.get("claim_scope")),
|
||||||
|
relation_id=relation_id,
|
||||||
|
retrieval_methods=[method],
|
||||||
|
retrieval_queries=[query],
|
||||||
|
vector_score=float(payload.get("quality_score") or payload.get("best_score") or 0.0),
|
||||||
|
rrf_score=float(payload.get("quality_score") or payload.get("best_score") or 0.0),
|
||||||
|
raw=dict(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
def extract_seeds(self, hypothesis: RetrievalHypothesis, items: List[EvidenceItem]) -> Tuple[List[str], List[str]]:
|
||||||
|
ids: List[str] = []
|
||||||
|
names: List[str] = []
|
||||||
|
|
||||||
|
def add_id(value: Any) -> None:
|
||||||
|
text = _text(value)
|
||||||
|
if text and text.startswith("cent_") and text not in ids:
|
||||||
|
ids.append(text)
|
||||||
|
|
||||||
|
def add_name(value: Any) -> None:
|
||||||
|
text = _norm(value)
|
||||||
|
if 2 <= len(text) <= 160 and text not in names:
|
||||||
|
names.append(text)
|
||||||
|
|
||||||
|
for entity in hypothesis.direct_question_entities + hypothesis.hypothesized_entities:
|
||||||
|
add_name(entity.canonical_candidate)
|
||||||
|
add_name(entity.mention)
|
||||||
|
|
||||||
|
for item in items[:50]:
|
||||||
|
raw = item.raw or {}
|
||||||
|
add_id(raw.get("source_canonical_id"))
|
||||||
|
add_id(raw.get("target_canonical_id"))
|
||||||
|
add_name(item.source)
|
||||||
|
add_name(item.target)
|
||||||
|
|
||||||
|
return ids, names
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ids_from_items(items: Iterable[EvidenceItem]) -> List[str]:
|
||||||
|
ids: List[str] = []
|
||||||
|
for item in items:
|
||||||
|
raw = item.raw or {}
|
||||||
|
for value in (raw.get("source_canonical_id"), raw.get("target_canonical_id")):
|
||||||
|
text = _text(value)
|
||||||
|
if text.startswith("cent_") and text not in ids:
|
||||||
|
ids.append(text)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _flatten_queries(question: str, queries: Dict[str, List[str]]) -> List[str]:
|
||||||
|
output: List[str] = []
|
||||||
|
for value in [question]:
|
||||||
|
if value and value not in output:
|
||||||
|
output.append(value)
|
||||||
|
for lane_values in queries.values():
|
||||||
|
for value in lane_values:
|
||||||
|
clean = _text(value)
|
||||||
|
if clean and clean not in output:
|
||||||
|
output.append(clean)
|
||||||
|
return output[:10]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dedup(items: List[EvidenceItem]) -> List[EvidenceItem]:
|
||||||
|
by_id: Dict[str, EvidenceItem] = {}
|
||||||
|
no_id: List[EvidenceItem] = []
|
||||||
|
for item in items:
|
||||||
|
key = item.relation_id
|
||||||
|
if not key:
|
||||||
|
no_id.append(item)
|
||||||
|
continue
|
||||||
|
existing = by_id.get(key)
|
||||||
|
if existing is None:
|
||||||
|
by_id[key] = item
|
||||||
|
continue
|
||||||
|
existing.retrieval_methods = list(dict.fromkeys(existing.retrieval_methods + item.retrieval_methods))
|
||||||
|
existing.retrieval_queries = list(dict.fromkeys(existing.retrieval_queries + item.retrieval_queries))
|
||||||
|
existing.vector_score = max(existing.vector_score, item.vector_score)
|
||||||
|
existing.rrf_score = max(existing.rrf_score, item.rrf_score)
|
||||||
|
if not existing.evidence and item.evidence:
|
||||||
|
existing.evidence = item.evidence
|
||||||
|
return list(by_id.values()) + no_id
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .schemas import HypothesisEntity, RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", str(value or "").strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_alias(text: str, alias: str) -> bool:
|
||||||
|
normalized_alias = _norm(alias)
|
||||||
|
if not normalized_alias:
|
||||||
|
return False
|
||||||
|
if re.search(r"[\u4e00-\u9fff]", normalized_alias):
|
||||||
|
return normalized_alias in text
|
||||||
|
return bool(
|
||||||
|
re.search(
|
||||||
|
rf"(?<![A-Za-z0-9]){re.escape(normalized_alias)}(?![A-Za-z0-9])",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DomainConfig:
|
||||||
|
def __init__(self, alias_path: Path, taxonomy_path: Path):
|
||||||
|
self.alias_path = alias_path
|
||||||
|
self.taxonomy_path = taxonomy_path
|
||||||
|
self.alias_entries: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self.alias_lookup: Dict[str, str] = {}
|
||||||
|
self.taxonomy: Dict[str, List[str]] = {}
|
||||||
|
self._load_aliases()
|
||||||
|
self._load_taxonomy()
|
||||||
|
|
||||||
|
def _load_aliases(self) -> None:
|
||||||
|
data = yaml.safe_load(self.alias_path.read_text(encoding="utf-8")) or {}
|
||||||
|
for canonical, payload in data.items():
|
||||||
|
payload = payload if isinstance(payload, dict) else {}
|
||||||
|
aliases = [str(item) for item in payload.get("aliases", []) or []]
|
||||||
|
aliases.append(str(canonical))
|
||||||
|
raw_retrieval_label = payload.get("retrieval_label")
|
||||||
|
retrieval_label = (
|
||||||
|
raw_retrieval_label.strip()
|
||||||
|
if isinstance(raw_retrieval_label, str)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
raw_retrieval_terms = payload.get("retrieval_terms")
|
||||||
|
retrieval_terms = (
|
||||||
|
[
|
||||||
|
item.strip()
|
||||||
|
for item in raw_retrieval_terms
|
||||||
|
if isinstance(item, str) and item.strip()
|
||||||
|
]
|
||||||
|
if isinstance(raw_retrieval_terms, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
entry = {
|
||||||
|
"canonical": str(canonical),
|
||||||
|
"type": str(payload.get("type") or ""),
|
||||||
|
"aliases": list(dict.fromkeys(aliases)),
|
||||||
|
"retrieval_label": retrieval_label,
|
||||||
|
"retrieval_terms": list(dict.fromkeys(retrieval_terms)),
|
||||||
|
"forbidden_expansions": [
|
||||||
|
str(item)
|
||||||
|
for item in payload.get("forbidden_expansions", []) or []
|
||||||
|
],
|
||||||
|
}
|
||||||
|
self.alias_entries[str(canonical)] = entry
|
||||||
|
for alias in entry["aliases"]:
|
||||||
|
self.alias_lookup[_norm(alias)] = str(canonical)
|
||||||
|
|
||||||
|
def _load_taxonomy(self) -> None:
|
||||||
|
data = yaml.safe_load(self.taxonomy_path.read_text(encoding="utf-8")) or {}
|
||||||
|
for intent, payload in data.items():
|
||||||
|
values: List[str] = []
|
||||||
|
if isinstance(payload, list):
|
||||||
|
values.extend(str(item) for item in payload)
|
||||||
|
elif isinstance(payload, dict):
|
||||||
|
for group in payload.values():
|
||||||
|
if isinstance(group, list):
|
||||||
|
values.extend(str(item) for item in group)
|
||||||
|
self.taxonomy[str(intent)] = list(dict.fromkeys(values))
|
||||||
|
|
||||||
|
def canonicalize_entity(self, entity: HypothesisEntity) -> HypothesisEntity:
|
||||||
|
canonical = self.alias_lookup.get(
|
||||||
|
_norm(entity.canonical_candidate),
|
||||||
|
self.alias_lookup.get(_norm(entity.mention), entity.canonical_candidate),
|
||||||
|
)
|
||||||
|
entry = self.alias_entries.get(canonical, {})
|
||||||
|
entity.canonical_candidate = canonical
|
||||||
|
if not entity.entity_type or entity.entity_type == "unknown":
|
||||||
|
entity.entity_type = str(entry.get("type") or "unknown")
|
||||||
|
if entry:
|
||||||
|
entity.verification_status = "verified_alias"
|
||||||
|
entity.matched_kb_entity = canonical
|
||||||
|
entity.use_as_graph_seed = True
|
||||||
|
elif entity.origin == "question":
|
||||||
|
entity.verification_status = "question_direct_unverified"
|
||||||
|
entity.use_as_graph_seed = True
|
||||||
|
return entity
|
||||||
|
|
||||||
|
def normalize_hypothesis(self, hypothesis: RetrievalHypothesis) -> Dict[str, Any]:
|
||||||
|
audit = []
|
||||||
|
for entity in (
|
||||||
|
hypothesis.direct_question_entities + hypothesis.hypothesized_entities
|
||||||
|
):
|
||||||
|
before = entity.canonical_candidate
|
||||||
|
self.canonicalize_entity(entity)
|
||||||
|
audit.append(
|
||||||
|
{
|
||||||
|
"mention": entity.mention,
|
||||||
|
"before": before,
|
||||||
|
"canonical": entity.canonical_candidate,
|
||||||
|
"origin": entity.origin,
|
||||||
|
"verification_status": entity.verification_status,
|
||||||
|
"use_as_graph_seed": entity.use_as_graph_seed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"entities": audit, "alias_config": str(self.alias_path)}
|
||||||
|
|
||||||
|
def direct_entities_from_question(self, question: str) -> List[HypothesisEntity]:
|
||||||
|
normalized_question = _norm(question)
|
||||||
|
entities: List[HypothesisEntity] = []
|
||||||
|
for canonical, entry in self.alias_entries.items():
|
||||||
|
matched_alias = next(
|
||||||
|
(
|
||||||
|
alias
|
||||||
|
for alias in sorted(entry["aliases"], key=len, reverse=True)
|
||||||
|
if _contains_alias(normalized_question, alias)
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if not matched_alias:
|
||||||
|
continue
|
||||||
|
entities.append(
|
||||||
|
HypothesisEntity(
|
||||||
|
mention=matched_alias,
|
||||||
|
canonical_candidate=canonical,
|
||||||
|
entity_type=str(entry.get("type") or "unknown"),
|
||||||
|
origin="question",
|
||||||
|
confidence=1.0,
|
||||||
|
verification_status="verified_alias",
|
||||||
|
matched_kb_entity=canonical,
|
||||||
|
use_as_graph_seed=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entities
|
||||||
|
|
||||||
|
def expanded_relations(self, intents: Iterable[str]) -> List[str]:
|
||||||
|
output: List[str] = []
|
||||||
|
for intent in intents:
|
||||||
|
output.extend(self.taxonomy.get(str(intent), []))
|
||||||
|
return list(dict.fromkeys(output))
|
||||||
|
|
||||||
|
def evidence_query_terms(
|
||||||
|
self, entities: Iterable[HypothesisEntity]
|
||||||
|
) -> List[str]:
|
||||||
|
verified_entries = [
|
||||||
|
self.alias_entries.get(entity.canonical_candidate, {})
|
||||||
|
for entity in entities
|
||||||
|
if entity.verification_status == "verified_alias"
|
||||||
|
]
|
||||||
|
evidence_terms = [
|
||||||
|
term
|
||||||
|
for entry in verified_entries
|
||||||
|
for term in entry.get("retrieval_terms", [])
|
||||||
|
]
|
||||||
|
if not evidence_terms:
|
||||||
|
return []
|
||||||
|
labels = [
|
||||||
|
entry.get("retrieval_label", "")
|
||||||
|
for entry in verified_entries
|
||||||
|
if entry.get("retrieval_label", "")
|
||||||
|
]
|
||||||
|
return list(dict.fromkeys(labels + evidence_terms))
|
||||||
|
|
||||||
|
def forbidden_terms(self, entities: Iterable[HypothesisEntity]) -> List[str]:
|
||||||
|
output: List[str] = []
|
||||||
|
for entity in entities:
|
||||||
|
entry = self.alias_entries.get(entity.canonical_candidate, {})
|
||||||
|
output.extend(entry.get("forbidden_expansions", []) or [])
|
||||||
|
return list(dict.fromkeys(output))
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Iterable, List
|
||||||
|
|
||||||
|
from .schemas import EvidenceItem, RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: str) -> str:
|
||||||
|
return " ".join(str(value or "").strip().lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
class EntityValidator:
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
evidence: Iterable[EvidenceItem],
|
||||||
|
) -> List[Dict]:
|
||||||
|
corpus = [
|
||||||
|
_norm(
|
||||||
|
" ".join(
|
||||||
|
[
|
||||||
|
item.source,
|
||||||
|
item.target,
|
||||||
|
item.evidence,
|
||||||
|
item.species,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for item in evidence
|
||||||
|
]
|
||||||
|
audit: List[Dict] = []
|
||||||
|
for entity in (
|
||||||
|
hypothesis.direct_question_entities + hypothesis.hypothesized_entities
|
||||||
|
):
|
||||||
|
candidates = {
|
||||||
|
_norm(entity.mention),
|
||||||
|
_norm(entity.canonical_candidate),
|
||||||
|
} - {""}
|
||||||
|
matched = any(
|
||||||
|
candidate in row
|
||||||
|
for candidate in candidates
|
||||||
|
for row in corpus
|
||||||
|
)
|
||||||
|
if matched:
|
||||||
|
entity.verification_status = (
|
||||||
|
"verified_alias_and_retrieval"
|
||||||
|
if entity.verification_status == "verified_alias"
|
||||||
|
else "verified_retrieval"
|
||||||
|
)
|
||||||
|
entity.matched_kb_entity = entity.canonical_candidate
|
||||||
|
entity.use_as_graph_seed = True
|
||||||
|
elif entity.origin == "provisional_answer":
|
||||||
|
entity.use_as_graph_seed = False
|
||||||
|
audit.append(
|
||||||
|
{
|
||||||
|
"mention": entity.mention,
|
||||||
|
"canonical": entity.canonical_candidate,
|
||||||
|
"origin": entity.origin,
|
||||||
|
"verification_status": entity.verification_status,
|
||||||
|
"matched_in_retrieval": matched,
|
||||||
|
"use_as_graph_seed": entity.use_as_graph_seed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return audit
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from .evidence_pack import format_evidence_pack
|
||||||
|
from .llm_client import LLMClient
|
||||||
|
from .schemas import EvidenceItem, RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceAssessor:
|
||||||
|
SYSTEM = """You assess whether retrieved plant-biology evidence is sufficient.
|
||||||
|
|
||||||
|
Evaluate coverage against the requested answer slots, not against the number of
|
||||||
|
retrieved records. Distinguish direct evidence, indirect evidence, prior work,
|
||||||
|
methods, datasets, predictions, correlations, and perturbation-based causal
|
||||||
|
evidence. Preserve species, tissue, treatment, time, genotype, negation, and
|
||||||
|
direction. Do not answer the user's question."""
|
||||||
|
|
||||||
|
def __init__(self, llm: LLMClient):
|
||||||
|
self.llm = llm
|
||||||
|
|
||||||
|
def assess(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
evidence: List[EvidenceItem],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return self.llm.complete_json(
|
||||||
|
self.SYSTEM,
|
||||||
|
f"""Question:
|
||||||
|
{question}
|
||||||
|
|
||||||
|
Question type:
|
||||||
|
{hypothesis.question_type}
|
||||||
|
|
||||||
|
Required answer slots:
|
||||||
|
{hypothesis.answer_slots}
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
{format_evidence_pack(evidence)}
|
||||||
|
|
||||||
|
Return:
|
||||||
|
{{
|
||||||
|
"covered_slots": [
|
||||||
|
{{"slot":"", "evidence_ids":["S1"], "support":"direct|indirect|weak"}}
|
||||||
|
],
|
||||||
|
"missing_slots": [""],
|
||||||
|
"weak_slots": [""],
|
||||||
|
"conflicts": [
|
||||||
|
{{"description":"", "evidence_ids":[""]}}
|
||||||
|
],
|
||||||
|
"follow_up_queries": [
|
||||||
|
{{"slot":"", "query":"", "relation_intents":[""]}}
|
||||||
|
],
|
||||||
|
"sufficient": true,
|
||||||
|
"reason": ""
|
||||||
|
}}""",
|
||||||
|
max_tokens=2500,
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
from .domain_config import DomainConfig
|
||||||
|
from .schemas import EvidenceItem, RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceFilter:
|
||||||
|
def __init__(self, domain: DomainConfig):
|
||||||
|
self.domain = domain
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
items: Iterable[EvidenceItem],
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
) -> Tuple[List[EvidenceItem], List[Dict]]:
|
||||||
|
entities = (
|
||||||
|
hypothesis.direct_question_entities + hypothesis.hypothesized_entities
|
||||||
|
)
|
||||||
|
forbidden = [value.lower() for value in self.domain.forbidden_terms(entities)]
|
||||||
|
kept: List[EvidenceItem] = []
|
||||||
|
audit: List[Dict] = []
|
||||||
|
for item in items:
|
||||||
|
text = " ".join(
|
||||||
|
[
|
||||||
|
item.source,
|
||||||
|
item.relation,
|
||||||
|
item.target,
|
||||||
|
item.evidence,
|
||||||
|
item.species,
|
||||||
|
]
|
||||||
|
).lower()
|
||||||
|
reasons: List[str] = []
|
||||||
|
if not item.source or not item.target:
|
||||||
|
reasons.append("missing_endpoint")
|
||||||
|
if not item.evidence and item.kb != "PC":
|
||||||
|
reasons.append("missing_evidence")
|
||||||
|
bad_hits = [term for term in forbidden if term and term in text]
|
||||||
|
if bad_hits:
|
||||||
|
reasons.append("forbidden_alias:" + ",".join(bad_hits))
|
||||||
|
action = "reject" if reasons else "keep"
|
||||||
|
audit.append(
|
||||||
|
{
|
||||||
|
"action": action,
|
||||||
|
"reason": ";".join(reasons),
|
||||||
|
"source": item.source,
|
||||||
|
"relation": item.relation,
|
||||||
|
"target": item.target,
|
||||||
|
"kb": item.kb,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not reasons:
|
||||||
|
kept.append(item)
|
||||||
|
return kept, audit
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from .citation_contract import public_citation
|
||||||
|
from .claim_context import build_claim_context
|
||||||
|
from .schemas import EvidenceItem
|
||||||
|
|
||||||
|
|
||||||
|
def assign_evidence_ids(items: List[EvidenceItem]) -> None:
|
||||||
|
for index, item in enumerate(items, 1):
|
||||||
|
item.evidence_id = f"S{index}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_evidence_pack(items: List[EvidenceItem]) -> str:
|
||||||
|
blocks = []
|
||||||
|
for item in items:
|
||||||
|
context = build_claim_context(item)
|
||||||
|
citation = public_citation(
|
||||||
|
evidence_id=item.evidence_id,
|
||||||
|
pmid=item.pmid,
|
||||||
|
pmcid=item.pmcid,
|
||||||
|
doi=item.doi,
|
||||||
|
)
|
||||||
|
blocks.append(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
f"[{item.evidence_id}]",
|
||||||
|
f"KB: {item.kb}",
|
||||||
|
f"Layer: {item.output_layer or 'unknown'}",
|
||||||
|
f"Paper: {item.title or item.paper_id or 'unknown'}",
|
||||||
|
f"PMID: {item.pmid or '-'}",
|
||||||
|
f"PMCID: {item.pmcid or '-'}",
|
||||||
|
f"DOI: {item.doi or '-'}",
|
||||||
|
f"Preferred citation: {citation or 'unavailable'}",
|
||||||
|
f"Section: {item.section or '-'}",
|
||||||
|
f"Claim species: {', '.join(context.claim_species) or '-'}",
|
||||||
|
f"Paper species: {', '.join(context.paper_species) or '-'}",
|
||||||
|
f"Species provenance: {context.species_source}",
|
||||||
|
f"Source: {item.source}",
|
||||||
|
f"Relation: {item.relation}",
|
||||||
|
f"Target: {item.target}",
|
||||||
|
f"Evidence: {item.evidence}",
|
||||||
|
f"Retrieval: {', '.join(item.retrieval_methods)}",
|
||||||
|
f"Score: {item.final_score:.6f}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "\n\n".join(blocks)
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
from neo4j import GraphDatabase
|
||||||
|
|
||||||
|
from .direct_flow_retriever import DirectFlowRetriever, _text
|
||||||
|
from .schemas import EvidenceItem, RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class FlatTop10SourceRetriever(DirectFlowRetriever):
|
||||||
|
"""Planner plus Qdrant-source seeds, then exact lookup in the flat graph.
|
||||||
|
|
||||||
|
Seed policy is intentionally explicit:
|
||||||
|
- retain graph-seed entities identified by the structured planner;
|
||||||
|
- globally sort Qdrant hits by vector score;
|
||||||
|
- retain the first 10 distinct ``source`` strings;
|
||||||
|
- merge and deduplicate those two seed sets;
|
||||||
|
- match only ``(:Entity {name: seed})``;
|
||||||
|
- never seed from vector targets, canonical IDs, or fuzzy names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
graph_schema: str | None = None,
|
||||||
|
vector_query_workers: int = 2,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
graph_schema="legacy",
|
||||||
|
vector_query_workers=vector_query_workers,
|
||||||
|
)
|
||||||
|
if self.driver is not None:
|
||||||
|
self.driver.close()
|
||||||
|
self.graph_schema = "flat_top10_source"
|
||||||
|
self.driver = GraphDatabase.driver(
|
||||||
|
self.neo4j_uri,
|
||||||
|
auth=(self.neo4j_user, self.neo4j_password),
|
||||||
|
)
|
||||||
|
self.source_seed_top_k = max(
|
||||||
|
1, min(int(os.environ.get("QA_FLAT_SOURCE_SEED_TOP_K", "10")), 10)
|
||||||
|
)
|
||||||
|
self.graph_hard_limit = max(
|
||||||
|
100,
|
||||||
|
min(int(os.environ.get("QA_FLAT_GRAPH_HARD_LIMIT", "2000")), 5000),
|
||||||
|
)
|
||||||
|
|
||||||
|
def retrieve(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
queries: Dict[str, List[str]],
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
*,
|
||||||
|
qdrant_top_k: int = 120,
|
||||||
|
graph_top_k: int = 80,
|
||||||
|
use_qdrant: bool = True,
|
||||||
|
use_neo4j: bool = True,
|
||||||
|
use_graph_expansion: bool = True,
|
||||||
|
) -> Tuple[List[EvidenceItem], Dict[str, Any]]:
|
||||||
|
audit: Dict[str, Any] = {
|
||||||
|
"policy": (
|
||||||
|
"planner_entities_plus_qdrant_vector_top10_distinct_source_"
|
||||||
|
"to_flat_neo4j_exact_name"
|
||||||
|
),
|
||||||
|
"qdrant": [],
|
||||||
|
"seed_extraction": {},
|
||||||
|
"neo4j": [],
|
||||||
|
"dedup": {},
|
||||||
|
"options": {
|
||||||
|
"qdrant_top_k": qdrant_top_k,
|
||||||
|
"requested_graph_top_k": graph_top_k,
|
||||||
|
"source_seed_top_k": self.source_seed_top_k,
|
||||||
|
"graph_hard_limit": self.graph_hard_limit,
|
||||||
|
"use_qdrant": bool(use_qdrant),
|
||||||
|
"use_neo4j": bool(use_neo4j),
|
||||||
|
"use_graph_expansion": bool(use_graph_expansion),
|
||||||
|
"canonical_ids_used": False,
|
||||||
|
"target_seeds_used": False,
|
||||||
|
"planner_seeds_used": True,
|
||||||
|
"fuzzy_name_matching_used": False,
|
||||||
|
},
|
||||||
|
"neo4j_status": "enabled_flat_exact_source" if use_neo4j else "disabled_by_request",
|
||||||
|
}
|
||||||
|
|
||||||
|
all_queries = self._flatten_queries(question, queries)
|
||||||
|
qdrant_items: List[EvidenceItem] = []
|
||||||
|
if use_qdrant:
|
||||||
|
per_query = max(10, min(qdrant_top_k // max(1, len(all_queries)), 50))
|
||||||
|
if self.vector_query_workers == 1 or len(all_queries) == 1:
|
||||||
|
query_rows = [
|
||||||
|
(query, self.qdrant_search(query, per_query))
|
||||||
|
for query in all_queries
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
with ThreadPoolExecutor(
|
||||||
|
max_workers=min(self.vector_query_workers, len(all_queries)),
|
||||||
|
thread_name_prefix="qa-flat-vector",
|
||||||
|
) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(self.qdrant_search, query, per_query)
|
||||||
|
for query in all_queries
|
||||||
|
]
|
||||||
|
query_rows = [
|
||||||
|
(query, future.result())
|
||||||
|
for query, future in zip(all_queries, futures)
|
||||||
|
]
|
||||||
|
for query, rows in query_rows:
|
||||||
|
audit["qdrant"].append({"query": query, "rows": len(rows)})
|
||||||
|
qdrant_items.extend(rows)
|
||||||
|
|
||||||
|
vector_sources = self._top_vector_sources(qdrant_items)
|
||||||
|
planner_sources = self._planner_sources(hypothesis, vector_sources)
|
||||||
|
sources = self._merge_graph_sources(planner_sources, vector_sources)
|
||||||
|
audit["seed_extraction"] = {
|
||||||
|
"seed_basis": (
|
||||||
|
"structured_planner_entities_union_"
|
||||||
|
"raw_qdrant_vector_top10_distinct_source"
|
||||||
|
),
|
||||||
|
"planner_seed_count": len(planner_sources),
|
||||||
|
"planner_seeds": planner_sources,
|
||||||
|
"vector_top10_source_seed_count": len(vector_sources),
|
||||||
|
"vector_top10_source_seeds": vector_sources,
|
||||||
|
"final_graph_seed_count": len(sources),
|
||||||
|
"final_graph_seeds": sources,
|
||||||
|
}
|
||||||
|
|
||||||
|
graph_items: List[EvidenceItem] = []
|
||||||
|
graph_audit: Dict[str, Any] = {
|
||||||
|
"requested_source_count": len(sources),
|
||||||
|
"matched_source_count": 0,
|
||||||
|
"unmatched_sources": [item["source"] for item in sources],
|
||||||
|
"rows": 0,
|
||||||
|
}
|
||||||
|
if use_neo4j and sources:
|
||||||
|
graph_items, graph_audit = self.flat_source_search(sources)
|
||||||
|
audit["neo4j"].append(graph_audit)
|
||||||
|
|
||||||
|
merged = self.dedup(qdrant_items + graph_items)
|
||||||
|
audit["dedup"] = {
|
||||||
|
"qdrant_items": len(qdrant_items),
|
||||||
|
"graph_items": len(graph_items),
|
||||||
|
"merged_items": len(merged),
|
||||||
|
}
|
||||||
|
return merged, audit
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _planner_sources(
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
vector_sources: List[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
vector_ceiling = max(
|
||||||
|
(float(item.get("vector_score") or 0.0) for item in vector_sources),
|
||||||
|
default=0.7,
|
||||||
|
)
|
||||||
|
entities = list(hypothesis.direct_question_entities or [])
|
||||||
|
entities.extend(
|
||||||
|
entity
|
||||||
|
for entity in (hypothesis.hypothesized_entities or [])
|
||||||
|
if bool(entity.use_as_graph_seed)
|
||||||
|
)
|
||||||
|
for entity in entities:
|
||||||
|
matched = _text(entity.matched_kb_entity)
|
||||||
|
if matched.startswith(("cent_", "cass_", "asrt_", "ent_")):
|
||||||
|
matched = ""
|
||||||
|
seed = matched or _text(entity.canonical_candidate) or _text(entity.mention)
|
||||||
|
if not seed or seed in seen:
|
||||||
|
continue
|
||||||
|
seen.add(seed)
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
"rank": len(output) + 1,
|
||||||
|
"source": seed,
|
||||||
|
"source_type": _text(entity.entity_type),
|
||||||
|
"vector_score": vector_ceiling,
|
||||||
|
"seed_origin": "planner",
|
||||||
|
"planner_mention": _text(entity.mention),
|
||||||
|
"planner_canonical_candidate": _text(entity.canonical_candidate),
|
||||||
|
"planner_confidence": float(entity.confidence or 0.0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_graph_sources(
|
||||||
|
planner_sources: List[Dict[str, Any]],
|
||||||
|
vector_sources: List[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
by_source: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for item in planner_sources + vector_sources:
|
||||||
|
source = _text(item.get("source"))
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
if source in by_source:
|
||||||
|
origins = by_source[source].setdefault("seed_origins", [])
|
||||||
|
origin = _text(item.get("seed_origin") or "vector_top10_source")
|
||||||
|
if origin not in origins:
|
||||||
|
origins.append(origin)
|
||||||
|
continue
|
||||||
|
record = dict(item)
|
||||||
|
origin = _text(record.get("seed_origin") or "vector_top10_source")
|
||||||
|
record["seed_origin"] = origin
|
||||||
|
record["seed_origins"] = [origin]
|
||||||
|
record["final_rank"] = len(output) + 1
|
||||||
|
by_source[source] = record
|
||||||
|
output.append(record)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _top_vector_sources(
|
||||||
|
self, items: List[EvidenceItem]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in sorted(
|
||||||
|
items,
|
||||||
|
key=lambda value: float(value.vector_score or 0.0),
|
||||||
|
reverse=True,
|
||||||
|
):
|
||||||
|
source = _text(item.source)
|
||||||
|
if not source or source in seen:
|
||||||
|
continue
|
||||||
|
seen.add(source)
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
"rank": len(output) + 1,
|
||||||
|
"source": source,
|
||||||
|
"source_type": _text((item.raw or {}).get("source_type")),
|
||||||
|
"vector_score": float(item.vector_score or 0.0),
|
||||||
|
"assertion_id": _text(item.relation_id),
|
||||||
|
"relation": _text(item.relation),
|
||||||
|
"target": _text(item.target),
|
||||||
|
"seed_origin": "vector_top10_source",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(output) >= self.source_seed_top_k:
|
||||||
|
break
|
||||||
|
return output
|
||||||
|
|
||||||
|
def flat_source_search(
|
||||||
|
self, sources: List[Dict[str, Any]]
|
||||||
|
) -> Tuple[List[EvidenceItem], Dict[str, Any]]:
|
||||||
|
statement = """
|
||||||
|
UNWIND $seeds AS seed
|
||||||
|
MATCH (s:Entity {name: seed.source})-[r:RELATION]->(t:Entity)
|
||||||
|
RETURN seed.rank AS seed_rank,
|
||||||
|
seed.source AS requested_source,
|
||||||
|
seed.vector_score AS seed_vector_score,
|
||||||
|
seed.seed_origin AS seed_origin,
|
||||||
|
s.entity_id AS source_entity_id,
|
||||||
|
s.name AS source,
|
||||||
|
s.entity_type AS source_type,
|
||||||
|
r.relation_id AS relation_id,
|
||||||
|
coalesce(r.relation_norm, r.relation) AS relation,
|
||||||
|
t.entity_id AS target_entity_id,
|
||||||
|
t.name AS target,
|
||||||
|
t.entity_type AS target_type,
|
||||||
|
r.evidence AS evidence,
|
||||||
|
r.paper_id AS paper_id,
|
||||||
|
r.pubmed_id AS pmid,
|
||||||
|
r.section AS section,
|
||||||
|
r.output_layer AS output_layer,
|
||||||
|
r.claim_scope AS claim_scope,
|
||||||
|
r.candidate_status AS candidate_status,
|
||||||
|
r.triple_quality AS triple_quality,
|
||||||
|
r.confidence AS confidence,
|
||||||
|
r.source_label AS source_label
|
||||||
|
LIMIT $hard_limit
|
||||||
|
"""
|
||||||
|
with self.driver.session() as session:
|
||||||
|
rows = [
|
||||||
|
dict(record)
|
||||||
|
for record in session.run(
|
||||||
|
statement,
|
||||||
|
seeds=sources,
|
||||||
|
hard_limit=self.graph_hard_limit,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
matched = {
|
||||||
|
_text(row.get("requested_source"))
|
||||||
|
for row in rows
|
||||||
|
if _text(row.get("requested_source"))
|
||||||
|
}
|
||||||
|
output: List[EvidenceItem] = []
|
||||||
|
for row in rows:
|
||||||
|
payload = {
|
||||||
|
"relation_id": row.get("relation_id"),
|
||||||
|
"assertion_id": row.get("relation_id"),
|
||||||
|
"source": row.get("source"),
|
||||||
|
"source_type": row.get("source_type"),
|
||||||
|
"source_entity_id": row.get("source_entity_id"),
|
||||||
|
"relation": row.get("relation"),
|
||||||
|
"relation_norm": row.get("relation"),
|
||||||
|
"target": row.get("target"),
|
||||||
|
"target_type": row.get("target_type"),
|
||||||
|
"target_entity_id": row.get("target_entity_id"),
|
||||||
|
"evidence": row.get("evidence"),
|
||||||
|
"paper_id": row.get("paper_id"),
|
||||||
|
"pmid": row.get("pmid"),
|
||||||
|
"section": row.get("section"),
|
||||||
|
"output_layer": row.get("output_layer"),
|
||||||
|
"claim_scope": row.get("claim_scope"),
|
||||||
|
"candidate_status": row.get("candidate_status"),
|
||||||
|
"triple_quality": row.get("triple_quality"),
|
||||||
|
"confidence": row.get("confidence"),
|
||||||
|
"source_label": row.get("source_label"),
|
||||||
|
}
|
||||||
|
item = self.item_from_payload(
|
||||||
|
payload,
|
||||||
|
"neo4j_flat_exact_source",
|
||||||
|
_text(row.get("requested_source")),
|
||||||
|
)
|
||||||
|
item.raw["graph_seed_origin"] = _text(row.get("seed_origin"))
|
||||||
|
seed_score = float(row.get("seed_vector_score") or 0.0)
|
||||||
|
item.vector_score = seed_score
|
||||||
|
item.rrf_score = max(item.rrf_score, seed_score * 0.98)
|
||||||
|
output.append(item)
|
||||||
|
|
||||||
|
requested = [_text(item.get("source")) for item in sources]
|
||||||
|
return output, {
|
||||||
|
"method": "neo4j_flat_exact_source",
|
||||||
|
"query_shape": (
|
||||||
|
"UNWIND planner + vector top10 source seeds -> "
|
||||||
|
"Entity{name} index seek -> outgoing RELATION"
|
||||||
|
),
|
||||||
|
"requested_source_count": len(requested),
|
||||||
|
"matched_source_count": len(matched),
|
||||||
|
"matched_sources": [name for name in requested if name in matched],
|
||||||
|
"unmatched_sources": [name for name in requested if name not in matched],
|
||||||
|
"rows": len(rows),
|
||||||
|
"hard_limit": self.graph_hard_limit,
|
||||||
|
"truncated": len(rows) >= self.graph_hard_limit,
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
from .schemas import EvidenceItem
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: str) -> str:
|
||||||
|
value = re.sub(r"\s+", " ", str(value or "").strip().lower())
|
||||||
|
return value.strip(" .;,:")
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_key(item: EvidenceItem) -> str:
|
||||||
|
if item.relation_id:
|
||||||
|
return f"{item.kb}:rid:{item.relation_id}"
|
||||||
|
raw = "|".join(
|
||||||
|
[
|
||||||
|
item.kb,
|
||||||
|
_norm(item.source),
|
||||||
|
_norm(item.relation),
|
||||||
|
_norm(item.target),
|
||||||
|
_norm(item.paper_id or item.pmid),
|
||||||
|
_norm(item.evidence),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def rrf_fuse(
|
||||||
|
items: Iterable[EvidenceItem], k: int = 60
|
||||||
|
) -> Tuple[List[EvidenceItem], List[Dict]]:
|
||||||
|
grouped: Dict[str, EvidenceItem] = {}
|
||||||
|
method_lists: Dict[str, List[EvidenceItem]] = defaultdict(list)
|
||||||
|
for item in items:
|
||||||
|
method = item.retrieval_methods[0] if item.retrieval_methods else "unknown"
|
||||||
|
method_lists[method].append(item)
|
||||||
|
|
||||||
|
scores: Dict[str, float] = defaultdict(float)
|
||||||
|
for method, method_items in method_lists.items():
|
||||||
|
method_items.sort(key=lambda value: -float(value.vector_score or 0.0))
|
||||||
|
for rank, item in enumerate(method_items, 1):
|
||||||
|
key = evidence_key(item)
|
||||||
|
scores[key] += 1.0 / (k + rank)
|
||||||
|
if key not in grouped:
|
||||||
|
grouped[key] = item
|
||||||
|
else:
|
||||||
|
current = grouped[key]
|
||||||
|
current.retrieval_methods = list(
|
||||||
|
dict.fromkeys(current.retrieval_methods + item.retrieval_methods)
|
||||||
|
)
|
||||||
|
current.retrieval_queries = list(
|
||||||
|
dict.fromkeys(current.retrieval_queries + item.retrieval_queries)
|
||||||
|
)
|
||||||
|
current.vector_score = max(current.vector_score, item.vector_score)
|
||||||
|
|
||||||
|
fused = list(grouped.values())
|
||||||
|
for item in fused:
|
||||||
|
item.rrf_score = scores[evidence_key(item)]
|
||||||
|
fused.sort(key=lambda value: -value.rrf_score)
|
||||||
|
audit = [
|
||||||
|
{
|
||||||
|
"rank": rank,
|
||||||
|
"key": evidence_key(item),
|
||||||
|
"rrf_score": item.rrf_score,
|
||||||
|
"retrieval_methods": item.retrieval_methods,
|
||||||
|
"retrieval_queries": item.retrieval_queries,
|
||||||
|
"source": item.source,
|
||||||
|
"relation": item.relation,
|
||||||
|
"target": item.target,
|
||||||
|
}
|
||||||
|
for rank, item in enumerate(fused, 1)
|
||||||
|
]
|
||||||
|
return fused, audit
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from .llm_client import LLMClient
|
||||||
|
from .schemas import AtomicClaim, HypothesisEntity, RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class HypothesisExtractor:
|
||||||
|
SYSTEM = """You structure retrieval hypotheses for a plant-biology evidence system.
|
||||||
|
|
||||||
|
Inputs are the original question and an unverified provisional answer.
|
||||||
|
Separate entities explicitly present in the question from entities introduced
|
||||||
|
by the provisional answer. Extract relation intents, concept queries, atomic
|
||||||
|
claims, answer slots, and negative constraints.
|
||||||
|
|
||||||
|
Preserve each entity `mention` exactly in the wording and language in which it
|
||||||
|
appears. When useful, normalize `canonical_candidate`, relation intents, and
|
||||||
|
English scientific retrieval variants to English. For a non-English question,
|
||||||
|
generate at least one concept query in the original question language plus
|
||||||
|
1-3 English scientific retrieval variants in the same planner call. Keep gene
|
||||||
|
symbols and Latin species names unchanged.
|
||||||
|
|
||||||
|
Do not judge the provisional answer as true. Entities, species, and claims
|
||||||
|
introduced by it are retrieval hypotheses only and require evidence
|
||||||
|
verification. Keep gene names species-specific when the species is visible.
|
||||||
|
Do not equate a species with its family or a crop with a different species."""
|
||||||
|
|
||||||
|
def __init__(self, llm: LLMClient):
|
||||||
|
self.llm = llm
|
||||||
|
|
||||||
|
def extract(self, question: str, provisional_answer: str) -> RetrievalHypothesis:
|
||||||
|
payload = self.llm.complete_json(
|
||||||
|
self.SYSTEM,
|
||||||
|
f"""Original question:
|
||||||
|
{question}
|
||||||
|
|
||||||
|
Unverified provisional answer:
|
||||||
|
{provisional_answer}
|
||||||
|
|
||||||
|
Return:
|
||||||
|
{{
|
||||||
|
"question_type": "fact|count|expression|function|mechanism|comparison|method|dataset|prior_work|survey",
|
||||||
|
"direct_question_entities": [
|
||||||
|
{{"mention":"", "canonical_candidate":"", "type":"gene|gene_family|protein|species|motif|trait|condition|unknown", "confidence":0.0}}
|
||||||
|
],
|
||||||
|
"hypothesized_entities": [
|
||||||
|
{{"mention":"", "canonical_candidate":"", "type":"", "confidence":0.0}}
|
||||||
|
],
|
||||||
|
"relation_intents": ["short intent names"],
|
||||||
|
"concept_queries": ["2-6 concise original-language and English scientific literature retrieval queries"],
|
||||||
|
"answer_slots": ["specific information components needed for a complete answer"],
|
||||||
|
"atomic_claims": [
|
||||||
|
{{"claim_id":"H1", "text":"one atomic factual claim", "entities":[""], "relation_hint":""}}
|
||||||
|
],
|
||||||
|
"negative_constraints": ["invalid entity equivalences or scope expansions to avoid"]
|
||||||
|
}}""",
|
||||||
|
max_tokens=3200,
|
||||||
|
)
|
||||||
|
return RetrievalHypothesis(
|
||||||
|
question_type=str(payload.get("question_type") or "fact"),
|
||||||
|
direct_question_entities=self._entities(
|
||||||
|
payload.get("direct_question_entities"), "question"
|
||||||
|
),
|
||||||
|
hypothesized_entities=self._entities(
|
||||||
|
payload.get("hypothesized_entities"), "provisional_answer"
|
||||||
|
),
|
||||||
|
relation_intents=self._strings(payload.get("relation_intents")),
|
||||||
|
concept_queries=self._strings(payload.get("concept_queries"))[:6],
|
||||||
|
answer_slots=self._strings(payload.get("answer_slots"))[:10],
|
||||||
|
atomic_claims=self._claims(payload.get("atomic_claims")),
|
||||||
|
negative_constraints=self._strings(payload.get("negative_constraints")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strings(values: Any) -> List[str]:
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return []
|
||||||
|
return list(dict.fromkeys(str(value).strip() for value in values if str(value).strip()))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _entities(values: Any, origin: str) -> List[HypothesisEntity]:
|
||||||
|
output: List[HypothesisEntity] = []
|
||||||
|
for value in values if isinstance(values, list) else []:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
mention = str(value.get("mention") or "").strip()
|
||||||
|
canonical = str(
|
||||||
|
value.get("canonical_candidate") or value.get("canonical") or mention
|
||||||
|
).strip()
|
||||||
|
if not mention and not canonical:
|
||||||
|
continue
|
||||||
|
output.append(
|
||||||
|
HypothesisEntity(
|
||||||
|
mention=mention or canonical,
|
||||||
|
canonical_candidate=canonical or mention,
|
||||||
|
entity_type=str(value.get("type") or "unknown"),
|
||||||
|
origin=origin,
|
||||||
|
confidence=float(value.get("confidence") or 0.0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _claims(values: Any) -> List[AtomicClaim]:
|
||||||
|
output: List[AtomicClaim] = []
|
||||||
|
for index, value in enumerate(values if isinstance(values, list) else [], 1):
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
text = str(value.get("text") or value.get("claim") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
output.append(
|
||||||
|
AtomicClaim(
|
||||||
|
claim_id=str(value.get("claim_id") or f"H{index}"),
|
||||||
|
text=text,
|
||||||
|
entities=[
|
||||||
|
str(item).strip()
|
||||||
|
for item in value.get("entities", []) or []
|
||||||
|
if str(item).strip()
|
||||||
|
],
|
||||||
|
relation_hint=str(value.get("relation_hint") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return output
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
from .citation_contract import public_citation
|
||||||
|
from .schemas import EvidenceItem
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class KnowledgeChainConfig:
|
||||||
|
chain_top_k: int = 3
|
||||||
|
chain_hops: int = 2
|
||||||
|
chain_max_edges: int = 12
|
||||||
|
chain_max_nodes: int = 10
|
||||||
|
chain_mode: str = "display"
|
||||||
|
chain_min_score: float = 0.45
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_values(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
chain_top_k: int | None = None,
|
||||||
|
chain_hops: int | None = None,
|
||||||
|
chain_max_edges: int | None = None,
|
||||||
|
chain_max_nodes: int | None = None,
|
||||||
|
chain_mode: str | None = None,
|
||||||
|
chain_min_score: float | None = None,
|
||||||
|
) -> "KnowledgeChainConfig":
|
||||||
|
mode = (chain_mode or "display").strip().lower()
|
||||||
|
if mode not in {"off", "display"}:
|
||||||
|
# V1.1.5 keeps the mode name compatible with older requests.
|
||||||
|
mode = "display"
|
||||||
|
return cls(
|
||||||
|
chain_top_k=max(0, min(int(chain_top_k if chain_top_k is not None else 3), 20)),
|
||||||
|
chain_hops=max(1, min(int(chain_hops if chain_hops is not None else 2), 2)),
|
||||||
|
chain_max_edges=max(0, min(int(chain_max_edges if chain_max_edges is not None else 12), 120)),
|
||||||
|
chain_max_nodes=max(0, min(int(chain_max_nodes if chain_max_nodes is not None else 10), 80)),
|
||||||
|
chain_mode=mode,
|
||||||
|
chain_min_score=max(0.0, min(float(chain_min_score if chain_min_score is not None else 0.45), 1.0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", str(value or "").strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _layer_weight(layer: str) -> float:
|
||||||
|
layer = (layer or "").strip()
|
||||||
|
return {
|
||||||
|
"main_kg": 1.0,
|
||||||
|
"dataset_metadata": 0.95,
|
||||||
|
"method_relations": 0.90,
|
||||||
|
"methodology_relations": 0.90,
|
||||||
|
"background_relations": 0.70,
|
||||||
|
"pc_main_kg": 0.85,
|
||||||
|
"candidate_review": 0.45,
|
||||||
|
}.get(layer, 0.55)
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_score(item: EvidenceItem, rank: int, total: int) -> float:
|
||||||
|
rank_score = 1.0 - ((rank - 1) / max(total, 1))
|
||||||
|
final = float(item.final_score or item.rerank_score or item.rrf_score or item.vector_score or 0.0)
|
||||||
|
if final <= 0:
|
||||||
|
final = rank_score
|
||||||
|
graph_bonus = 0.08 if any("neo4j" in m.lower() or "graph" in m.lower() for m in item.retrieval_methods) else 0.0
|
||||||
|
evidence_bonus = 0.05 if item.evidence else 0.0
|
||||||
|
return max(0.0, min(1.0, 0.45 * rank_score + 0.30 * min(final, 1.0) + 0.20 * _layer_weight(item.output_layer) + graph_bonus + evidence_bonus))
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_dict(item: EvidenceItem, rank: int, total: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"evidence_id": item.evidence_id,
|
||||||
|
"relation_id": item.relation_id,
|
||||||
|
"kb": item.kb,
|
||||||
|
"source": item.source,
|
||||||
|
"relation": item.relation,
|
||||||
|
"target": item.target,
|
||||||
|
"evidence": item.evidence,
|
||||||
|
"paper_id": item.paper_id,
|
||||||
|
"title": item.title,
|
||||||
|
"pmid": item.pmid,
|
||||||
|
"pmcid": item.pmcid,
|
||||||
|
"doi": item.doi,
|
||||||
|
"species": item.species,
|
||||||
|
"preferred_citation": public_citation(
|
||||||
|
evidence_id=item.evidence_id,
|
||||||
|
pmid=item.pmid,
|
||||||
|
pmcid=item.pmcid,
|
||||||
|
doi=item.doi,
|
||||||
|
),
|
||||||
|
"section": item.section,
|
||||||
|
"output_layer": item.output_layer,
|
||||||
|
"retrieval_methods": list(item.retrieval_methods),
|
||||||
|
"score": _edge_score(item, rank, total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeChainBuilder:
|
||||||
|
"""Build local relation chains from graph-derived evidence items.
|
||||||
|
|
||||||
|
V1.1.5 feeds these chains to the final answer LLM. The graph retrieval
|
||||||
|
itself still happens upstream; this class only organizes graph evidence
|
||||||
|
into one-hop and two-hop chains.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
selected: List[EvidenceItem],
|
||||||
|
config: KnowledgeChainConfig,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if config.chain_mode == "off" or config.chain_top_k <= 0:
|
||||||
|
return self._empty(config, "disabled")
|
||||||
|
total = len(selected)
|
||||||
|
edges: List[Dict[str, Any]] = []
|
||||||
|
seen_edges = set()
|
||||||
|
for rank, item in enumerate(selected, 1):
|
||||||
|
if not item.source or not item.relation or not item.target:
|
||||||
|
continue
|
||||||
|
key = (_norm(item.source), _norm(item.relation), _norm(item.target), item.relation_id)
|
||||||
|
if key in seen_edges:
|
||||||
|
continue
|
||||||
|
seen_edges.add(key)
|
||||||
|
edges.append(_edge_dict(item, rank, total))
|
||||||
|
if not edges:
|
||||||
|
return self._empty(config, "no_srt_edges")
|
||||||
|
|
||||||
|
chains: List[Dict[str, Any]] = []
|
||||||
|
if config.chain_hops >= 2:
|
||||||
|
chains.extend(self._two_hop_chains(edges, question))
|
||||||
|
chains.extend(self._one_hop_chains(edges, question))
|
||||||
|
|
||||||
|
chains.sort(key=lambda c: (-float(c.get("chain_score", 0.0)), c.get("hop_count", 0)))
|
||||||
|
selected_chains: List[Dict[str, Any]] = []
|
||||||
|
used_edges = 0
|
||||||
|
node_set = set()
|
||||||
|
seen_chain_keys = set()
|
||||||
|
for chain in chains:
|
||||||
|
if len(selected_chains) >= config.chain_top_k:
|
||||||
|
break
|
||||||
|
edge_count = len(chain.get("edges") or [])
|
||||||
|
nodes = [_norm(n.get("name")) for n in chain.get("nodes") or [] if n.get("name")]
|
||||||
|
chain_key = tuple((e.get("relation_id"), _norm(e.get("source")), _norm(e.get("relation")), _norm(e.get("target"))) for e in chain.get("edges") or [])
|
||||||
|
if chain_key in seen_chain_keys:
|
||||||
|
continue
|
||||||
|
if float(chain.get("chain_score", 0.0)) < config.chain_min_score and selected_chains:
|
||||||
|
continue
|
||||||
|
if used_edges + edge_count > config.chain_max_edges:
|
||||||
|
continue
|
||||||
|
if len(node_set | set(nodes)) > config.chain_max_nodes:
|
||||||
|
continue
|
||||||
|
seen_chain_keys.add(chain_key)
|
||||||
|
selected_chains.append(chain)
|
||||||
|
used_edges += edge_count
|
||||||
|
node_set.update(nodes)
|
||||||
|
|
||||||
|
graph = self._graph(selected_chains)
|
||||||
|
return {
|
||||||
|
"mode": config.chain_mode,
|
||||||
|
"chain_policy": "answer_context_from_graph_evidence",
|
||||||
|
"chain_top_k": config.chain_top_k,
|
||||||
|
"chain_hops": config.chain_hops,
|
||||||
|
"chain_min_score": config.chain_min_score,
|
||||||
|
"chains": selected_chains,
|
||||||
|
"chain_count": len(selected_chains),
|
||||||
|
"graph": graph,
|
||||||
|
"summary": {
|
||||||
|
"raw_edge_count": len(edges),
|
||||||
|
"display_chain_count": len(selected_chains),
|
||||||
|
"display_node_count": len(graph.get("nodes", [])),
|
||||||
|
"display_edge_count": len(graph.get("edges", [])),
|
||||||
|
"truncated": len(selected_chains) < len(chains),
|
||||||
|
"note": "Chains are built from graph-derived evidence and are sent to the final answer LLM in V1.1.5.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _two_hop_chains(self, edges: List[Dict[str, Any]], question: str) -> List[Dict[str, Any]]:
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
q = _norm(question)
|
||||||
|
for e1 in edges:
|
||||||
|
for e2 in edges:
|
||||||
|
if e1 is e2:
|
||||||
|
continue
|
||||||
|
if _norm(e1["target"]) != _norm(e2["source"]):
|
||||||
|
continue
|
||||||
|
score = 0.52 * ((e1["score"] + e2["score"]) / 2.0) + 0.18
|
||||||
|
for value in (e1["source"], e1["target"], e2["target"]):
|
||||||
|
if _norm(value) and _norm(value) in q:
|
||||||
|
score += 0.05
|
||||||
|
score = min(score, 1.0)
|
||||||
|
output.append({
|
||||||
|
"chain_id": f"chain_{len(output)+1:04d}",
|
||||||
|
"hop_count": 2,
|
||||||
|
"start": e1["source"],
|
||||||
|
"bridge": e1["target"],
|
||||||
|
"end": e2["target"],
|
||||||
|
"chain_score": score,
|
||||||
|
"interpretation": f"{e1['source']} is connected to {e2['target']} through {e1['target']}.",
|
||||||
|
"nodes": [
|
||||||
|
{"id": _norm(e1["source"]), "name": e1["source"], "role": "start"},
|
||||||
|
{"id": _norm(e1["target"]), "name": e1["target"], "role": "bridge"},
|
||||||
|
{"id": _norm(e2["target"]), "name": e2["target"], "role": "end"},
|
||||||
|
],
|
||||||
|
"edges": [e1, e2],
|
||||||
|
})
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _one_hop_chains(self, edges: List[Dict[str, Any]], question: str) -> List[Dict[str, Any]]:
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
q = _norm(question)
|
||||||
|
for e in edges:
|
||||||
|
score = 0.70 * e["score"]
|
||||||
|
if _norm(e["source"]) in q or _norm(e["target"]) in q:
|
||||||
|
score += 0.08
|
||||||
|
score = min(score, 1.0)
|
||||||
|
output.append({
|
||||||
|
"chain_id": f"direct_{len(output)+1:04d}",
|
||||||
|
"hop_count": 1,
|
||||||
|
"start": e["source"],
|
||||||
|
"bridge": "",
|
||||||
|
"end": e["target"],
|
||||||
|
"chain_score": score,
|
||||||
|
"interpretation": f"{e['source']} {e['relation']} {e['target']}.",
|
||||||
|
"nodes": [
|
||||||
|
{"id": _norm(e["source"]), "name": e["source"], "role": "start"},
|
||||||
|
{"id": _norm(e["target"]), "name": e["target"], "role": "end"},
|
||||||
|
],
|
||||||
|
"edges": [e],
|
||||||
|
})
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _graph(chains: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
nodes: Dict[str, Dict[str, Any]] = {}
|
||||||
|
edges: List[Dict[str, Any]] = []
|
||||||
|
seen_edges = set()
|
||||||
|
for chain in chains:
|
||||||
|
for node in chain.get("nodes") or []:
|
||||||
|
if not node.get("id"):
|
||||||
|
continue
|
||||||
|
nodes.setdefault(node["id"], dict(node))
|
||||||
|
for edge in chain.get("edges") or []:
|
||||||
|
source_id = _norm(edge.get("source"))
|
||||||
|
target_id = _norm(edge.get("target"))
|
||||||
|
key = (source_id, edge.get("relation"), target_id, edge.get("relation_id"))
|
||||||
|
if key in seen_edges:
|
||||||
|
continue
|
||||||
|
seen_edges.add(key)
|
||||||
|
edges.append({
|
||||||
|
"id": edge.get("relation_id") or f"{source_id}->{target_id}:{len(edges)+1}",
|
||||||
|
"source": source_id,
|
||||||
|
"target": target_id,
|
||||||
|
"source_label": edge.get("source"),
|
||||||
|
"target_label": edge.get("target"),
|
||||||
|
"relation": edge.get("relation"),
|
||||||
|
"kb": edge.get("kb"),
|
||||||
|
"evidence_id": edge.get("evidence_id"),
|
||||||
|
"score": edge.get("score", 0.0),
|
||||||
|
})
|
||||||
|
return {"nodes": list(nodes.values()), "edges": edges}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _empty(config: KnowledgeChainConfig, reason: str) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"mode": config.chain_mode,
|
||||||
|
"chain_policy": "answer_context_from_graph_evidence",
|
||||||
|
"chain_top_k": config.chain_top_k,
|
||||||
|
"chain_hops": config.chain_hops,
|
||||||
|
"chains": [],
|
||||||
|
"chain_count": 0,
|
||||||
|
"graph": {"nodes": [], "edges": []},
|
||||||
|
"summary": {
|
||||||
|
"raw_edge_count": 0,
|
||||||
|
"display_chain_count": 0,
|
||||||
|
"display_node_count": 0,
|
||||||
|
"display_edge_count": 0,
|
||||||
|
"truncated": False,
|
||||||
|
"reason": reason,
|
||||||
|
"note": "Chains are built from graph-derived evidence and are sent to the final answer LLM in V1.1.5.",
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMResponseError(RuntimeError):
|
||||||
|
"""Raised only after all bounded upstream recovery attempts are exhausted."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.api_key = (
|
||||||
|
os.environ.get("QA_V1_LLM_API_KEY")
|
||||||
|
or os.environ.get("DEEPSEEK_API_KEY")
|
||||||
|
or os.environ.get("PC_AGENTIC_QA_API_KEY")
|
||||||
|
)
|
||||||
|
self.base_url = (
|
||||||
|
os.environ.get("QA_V1_LLM_BASE_URL")
|
||||||
|
or os.environ.get("DEEPSEEK_BASE_URL")
|
||||||
|
or "https://api.deepseek.com"
|
||||||
|
).rstrip("/")
|
||||||
|
self.model = (
|
||||||
|
os.environ.get("QA_V1_LLM_MODEL")
|
||||||
|
or os.environ.get("V15_KB_QA_LLM_MODEL")
|
||||||
|
or os.environ.get("V15_RELATION_LLM_MODEL")
|
||||||
|
or "deepseek-v4-flash"
|
||||||
|
)
|
||||||
|
self.timeout = float(os.environ.get("QA_V1_LLM_TIMEOUT", "120"))
|
||||||
|
self.max_attempts = max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_LLM_MAX_ATTEMPTS", "4")), 8)
|
||||||
|
)
|
||||||
|
self.retry_base_seconds = max(
|
||||||
|
0.0, float(os.environ.get("QA_V1_LLM_RETRY_BASE_SECONDS", "1.5"))
|
||||||
|
)
|
||||||
|
self.retry_max_seconds = max(
|
||||||
|
self.retry_base_seconds,
|
||||||
|
float(os.environ.get("QA_V1_LLM_RETRY_MAX_SECONDS", "12")),
|
||||||
|
)
|
||||||
|
self.json_attempts = max(
|
||||||
|
1, min(int(os.environ.get("QA_V1_LLM_JSON_ATTEMPTS", "2")), 4)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return bool(self.api_key)
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
system: str,
|
||||||
|
user: str,
|
||||||
|
*,
|
||||||
|
temperature: float = 0.1,
|
||||||
|
max_tokens: int = 2000,
|
||||||
|
json_mode: bool = False,
|
||||||
|
request_attempts: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
if not self.api_key:
|
||||||
|
raise RuntimeError("QA V1 LLM API key is not configured")
|
||||||
|
request_body = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
],
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
if json_mode:
|
||||||
|
request_body["response_format"] = {"type": "json_object"}
|
||||||
|
attempt_limit = self.max_attempts
|
||||||
|
if request_attempts is not None:
|
||||||
|
attempt_limit = max(1, min(int(request_attempts), self.max_attempts))
|
||||||
|
last_error = "unknown upstream failure"
|
||||||
|
for attempt in range(1, attempt_limit + 1):
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=request_body,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise LLMResponseError(
|
||||||
|
f"LLM HTTP {response.status_code} returned non-JSON content"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
last_error = f"LLM HTTP {response.status_code}: {payload}"
|
||||||
|
if response.status_code not in {
|
||||||
|
408,
|
||||||
|
409,
|
||||||
|
425,
|
||||||
|
429,
|
||||||
|
500,
|
||||||
|
502,
|
||||||
|
503,
|
||||||
|
504,
|
||||||
|
}:
|
||||||
|
raise LLMResponseError(last_error)
|
||||||
|
raise requests.HTTPError(last_error, response=response)
|
||||||
|
|
||||||
|
choices = payload.get("choices") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(choices, list) or not choices:
|
||||||
|
last_error = "LLM response contains no choices"
|
||||||
|
raise LLMResponseError(last_error)
|
||||||
|
choice = choices[0] if isinstance(choices[0], dict) else {}
|
||||||
|
message = choice.get("message") if isinstance(choice, dict) else {}
|
||||||
|
content = self._message_text(message)
|
||||||
|
finish_reason = str(choice.get("finish_reason") or "")
|
||||||
|
if finish_reason.strip().lower() in {"length", "max_tokens"}:
|
||||||
|
last_error = (
|
||||||
|
"LLM output was truncated by the token limit; "
|
||||||
|
f"content_chars={len(content)}"
|
||||||
|
)
|
||||||
|
raise LLMResponseError(last_error)
|
||||||
|
if content:
|
||||||
|
if attempt > 1:
|
||||||
|
LOGGER.warning(
|
||||||
|
"LLM call recovered on attempt %s/%s after %.2fs; "
|
||||||
|
"finish_reason=%s content_chars=%s",
|
||||||
|
attempt,
|
||||||
|
attempt_limit,
|
||||||
|
time.perf_counter() - started,
|
||||||
|
finish_reason,
|
||||||
|
len(content),
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
last_error = (
|
||||||
|
"LLM returned empty message.content; "
|
||||||
|
f"finish_reason={finish_reason or '<missing>'}; "
|
||||||
|
f"has_reasoning_content={bool((message or {}).get('reasoning_content'))}"
|
||||||
|
)
|
||||||
|
raise LLMResponseError(last_error)
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
LOGGER.warning(
|
||||||
|
"LLM call attempt %s/%s failed after %.2fs: %s",
|
||||||
|
attempt,
|
||||||
|
attempt_limit,
|
||||||
|
time.perf_counter() - started,
|
||||||
|
last_error[:800],
|
||||||
|
)
|
||||||
|
if attempt >= attempt_limit:
|
||||||
|
break
|
||||||
|
self._sleep_before_retry(attempt)
|
||||||
|
raise LLMResponseError(
|
||||||
|
f"LLM failed after {attempt_limit} attempts: {last_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def complete_json(
|
||||||
|
self,
|
||||||
|
system: str,
|
||||||
|
user: str,
|
||||||
|
*,
|
||||||
|
max_tokens: int = 2500,
|
||||||
|
request_attempts: int | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
text = ""
|
||||||
|
for json_attempt in range(1, self.json_attempts + 1):
|
||||||
|
repair_note = ""
|
||||||
|
if json_attempt > 1:
|
||||||
|
repair_note = (
|
||||||
|
"\nA previous response was not a complete JSON object. Return a "
|
||||||
|
"single compact JSON object with every object and array closed."
|
||||||
|
)
|
||||||
|
text = self.complete(
|
||||||
|
system
|
||||||
|
+ "\nReturn valid JSON only. Do not use Markdown fences."
|
||||||
|
+ repair_note,
|
||||||
|
user,
|
||||||
|
temperature=0.0,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
json_mode=True,
|
||||||
|
request_attempts=request_attempts,
|
||||||
|
)
|
||||||
|
parsed = self._parse_json_object(text)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
LOGGER.warning(
|
||||||
|
"LLM JSON parse attempt %s/%s failed; raw_chars=%s",
|
||||||
|
json_attempt,
|
||||||
|
self.json_attempts,
|
||||||
|
len(text),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"_json_parse_error": "LLM returned JSON that could not be parsed",
|
||||||
|
"_raw_text_head": text[:1200],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _message_text(message: Any) -> str:
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
return ""
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: List[str] = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, str):
|
||||||
|
parts.append(block)
|
||||||
|
elif isinstance(block, dict):
|
||||||
|
value = block.get("text") or block.get("content")
|
||||||
|
if isinstance(value, str):
|
||||||
|
parts.append(value)
|
||||||
|
return "\n".join(part.strip() for part in parts if part.strip()).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _sleep_before_retry(self, attempt: int) -> None:
|
||||||
|
delay = min(
|
||||||
|
self.retry_max_seconds,
|
||||||
|
self.retry_base_seconds * (2 ** max(0, attempt - 1)),
|
||||||
|
)
|
||||||
|
if delay <= 0:
|
||||||
|
return
|
||||||
|
time.sleep(delay + random.uniform(0.0, min(0.5, delay * 0.2)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_json_object(text: str) -> Dict[str, Any] | None:
|
||||||
|
candidates: List[str] = []
|
||||||
|
stripped = text.strip()
|
||||||
|
if stripped:
|
||||||
|
candidates.append(stripped)
|
||||||
|
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.S | re.I)
|
||||||
|
if fenced:
|
||||||
|
candidates.append(fenced.group(1))
|
||||||
|
balanced = LLMClient._extract_balanced_object(text)
|
||||||
|
if balanced:
|
||||||
|
candidates.append(balanced)
|
||||||
|
greedy = re.search(r"\{.*\}", text, flags=re.S)
|
||||||
|
if greedy:
|
||||||
|
candidates.append(greedy.group(0))
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
candidate = candidate.strip()
|
||||||
|
if not candidate or candidate in seen:
|
||||||
|
continue
|
||||||
|
seen.add(candidate)
|
||||||
|
for fixed in LLMClient._json_fixups(candidate):
|
||||||
|
try:
|
||||||
|
value = json.loads(fixed)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_balanced_object(text: str) -> str | None:
|
||||||
|
start = text.find("{")
|
||||||
|
if start < 0:
|
||||||
|
return None
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escape = False
|
||||||
|
for index in range(start, len(text)):
|
||||||
|
char = text[index]
|
||||||
|
if in_string:
|
||||||
|
if escape:
|
||||||
|
escape = False
|
||||||
|
elif char == "\\":
|
||||||
|
escape = True
|
||||||
|
elif char == '"':
|
||||||
|
in_string = False
|
||||||
|
continue
|
||||||
|
if char == '"':
|
||||||
|
in_string = True
|
||||||
|
elif char == "{":
|
||||||
|
depth += 1
|
||||||
|
elif char == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return text[start : index + 1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json_fixups(candidate: str) -> List[str]:
|
||||||
|
fixed = candidate.replace("\ufeff", "").strip()
|
||||||
|
fixed = fixed.replace("“", '"').replace("”", '"').replace("‘", "'").replace("’", "'")
|
||||||
|
variants = [fixed]
|
||||||
|
no_trailing = re.sub(r",\s*([}\]])", r"\1", fixed)
|
||||||
|
variants.append(no_trailing)
|
||||||
|
comma_between_fields = re.sub(r'("(?:[^"\\]|\\.)*"|\]|\})\s*\n\s*(")', r'\1,\n\2', no_trailing)
|
||||||
|
variants.append(comma_between_fields)
|
||||||
|
comma_between_array_items = re.sub(r'(\}|\])\s*\n\s*(\{|\[)', r'\1,\n\2', comma_between_fields)
|
||||||
|
variants.append(comma_between_array_items)
|
||||||
|
output: List[str] = []
|
||||||
|
for item in variants:
|
||||||
|
if item not in output:
|
||||||
|
output.append(item)
|
||||||
|
return output
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Deterministic question-text compiler for natural-English graph QA."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NaturalQuestionPlan:
|
||||||
|
start_mentions: list[str]
|
||||||
|
visible_entities: list[str]
|
||||||
|
atomic_clauses: list[str]
|
||||||
|
relation_intents: list[str]
|
||||||
|
answer_type: str
|
||||||
|
estimated_hops: int
|
||||||
|
answer_mode: str = "terminal"
|
||||||
|
audit: dict[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class NaturalQuestionCompiler:
|
||||||
|
"""Compile only information visible in a question; accepts no gold fields."""
|
||||||
|
|
||||||
|
_RELATION_CUES = (
|
||||||
|
(r"\brespond(?:ed|s)?\s+to\b", "responds_to"),
|
||||||
|
(r"\binteract(?:ed|s|ion)?\s+with\b", "interacts_with"),
|
||||||
|
(r"\bhomolog(?:ous|y)\b", "homology"),
|
||||||
|
(r"\bcontain(?:ed|ing|s)?\b", "contains"),
|
||||||
|
(r"\breared\s+in\b", "reared_in"),
|
||||||
|
(r"\bexpress(?:ed|ion)?\b", "expression"),
|
||||||
|
(r"\blocali[sz](?:ed|es|ation)?\b", "localization"),
|
||||||
|
(r"\bencod(?:e|ed|es|ing)\b", "encodes"),
|
||||||
|
(r"\bregulat(?:e|ed|es|ing|ion)\b", "regulation"),
|
||||||
|
(r"\bused\s+(?:for|to)\b", "method_use"),
|
||||||
|
(r"\bcollected\s+(?:from|at)\b", "collection"),
|
||||||
|
(r"\bidentified\s+(?:as|in|by)\b", "identification"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def compile(self, question: str) -> NaturalQuestionPlan:
|
||||||
|
text = " ".join(str(question or "").split())
|
||||||
|
clauses = self._split_clauses(text)
|
||||||
|
start = self._start_mentions(text)
|
||||||
|
visible = self._visible_entities(text, start)
|
||||||
|
answer_mode = self._answer_mode(text)
|
||||||
|
intents = [name for pattern, name in self._RELATION_CUES if re.search(pattern, text, re.I)]
|
||||||
|
if answer_mode == "open_ended_summary":
|
||||||
|
intents.append("function")
|
||||||
|
return NaturalQuestionPlan(
|
||||||
|
start_mentions=start,
|
||||||
|
visible_entities=visible,
|
||||||
|
atomic_clauses=clauses,
|
||||||
|
relation_intents=list(dict.fromkeys(intents)),
|
||||||
|
answer_type=self._answer_type(text),
|
||||||
|
estimated_hops=self._estimated_hops(text, clauses),
|
||||||
|
answer_mode=answer_mode,
|
||||||
|
audit={"compiler": "natural_question_text_only_v1", "question_length": len(text)},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _answer_mode(text: str) -> str:
|
||||||
|
lowered = text.casefold()
|
||||||
|
function_cue = any(cue in lowered for cue in ("功能", "作用", "function", "functions", "role", "roles"))
|
||||||
|
enumeration_cue = any(cue in lowered for cue in ("哪些", "有何", "是什么", "what", "which", "list"))
|
||||||
|
return "open_ended_summary" if function_cue and enumeration_cue else "terminal"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_clauses(text: str) -> list[str]:
|
||||||
|
protected = re.sub(r"\b([A-Z])\.", r"\1<prd>", text)
|
||||||
|
protected = re.sub(r"\b(spp|subsp|var|cv)\.", r"\1<prd>", protected, flags=re.I)
|
||||||
|
return [part.replace("<prd>", ".").strip() for part in re.split(r"(?<=[.!?])\s+", protected) if part.strip()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _start_mentions(text: str) -> list[str]:
|
||||||
|
match = re.search(r"^For\s+([^,?]+),", text, re.I)
|
||||||
|
if match:
|
||||||
|
return [match.group(1).strip()]
|
||||||
|
match = re.search(r"\bwas\s+(.+?)\s+reported\s+to\b", text, re.I)
|
||||||
|
if match and re.match(r"^(?:which|what|where|when|in which)\b", text, re.I):
|
||||||
|
return [match.group(1).strip()]
|
||||||
|
match = re.match(
|
||||||
|
r"^(.+?)\s+(?:was|is|showed|maintained|responded|contained|accumulated)\b",
|
||||||
|
text, re.I,
|
||||||
|
)
|
||||||
|
return [match.group(1).strip()] if match else []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _visible_entities(text: str, start: list[str]) -> list[str]:
|
||||||
|
values: list[str] = list(start)
|
||||||
|
|
||||||
|
def add(value: str) -> None:
|
||||||
|
cleaned = value.strip(" ,.;?")
|
||||||
|
if cleaned and cleaned.casefold() not in {item.casefold() for item in values}:
|
||||||
|
values.append(cleaned)
|
||||||
|
|
||||||
|
reared = re.search(r"reared\s+in\s+(.+?)\s+containing\s+(.+?)[.]", text, re.I)
|
||||||
|
if reared:
|
||||||
|
add(reared.group(1))
|
||||||
|
add(reared.group(2))
|
||||||
|
homology = re.search(
|
||||||
|
r"^(.+?)\s+is\s+homologous\s+to\s+([^,]+),.*?involving\s+([A-Za-z0-9+.-]+).*?resolving\s+.+?\s+as\s+([^,]+),",
|
||||||
|
text, re.I,
|
||||||
|
)
|
||||||
|
if homology:
|
||||||
|
for value in homology.groups()[1:]:
|
||||||
|
add(value)
|
||||||
|
protected = re.sub(r"\b([A-Z])\.", r"\1<prd>", text)
|
||||||
|
protected = re.sub(r"\b(spp|subsp|var|cv)\.", r"\1<prd>", protected, flags=re.I)
|
||||||
|
clauses = [part.replace("<prd>", ".").strip() for part in re.split(r"(?<=[.!?])\s+", protected) if part.strip()]
|
||||||
|
for clause in clauses:
|
||||||
|
if re.match(r"^(?:According|What|Which|Where|When|At what)\b", clause, re.I):
|
||||||
|
continue
|
||||||
|
leading = re.match(
|
||||||
|
r"^(.+?)\s+(?:was|is|showed|maintained|contained|accumulated|responded|participated|interacted|bound)\b",
|
||||||
|
clause, re.I,
|
||||||
|
)
|
||||||
|
if leading:
|
||||||
|
candidate = leading.group(1)
|
||||||
|
if not re.search(r"\b(?:what|which|where|when)\b", candidate, re.I) \
|
||||||
|
and not re.match(r"^(?:this|that|these|those)\b", candidate, re.I):
|
||||||
|
add(candidate)
|
||||||
|
final_head = re.search(
|
||||||
|
r"(?:what did|what was co-expressed with|what targeted|which process involved|where was|at what .+? was)\s+(.+?)\s+"
|
||||||
|
r"(?:contain|accumulat|encod|bind|interact|express|sample|collect|enhance|cause|use|detect|promot|target)",
|
||||||
|
text, re.I,
|
||||||
|
)
|
||||||
|
if final_head:
|
||||||
|
add(final_head.group(1))
|
||||||
|
maintained = re.search(r"maintained\s+(?:substantial|high)\s+(.+?),\s+which.+?estimate\s+(.+?)[.]", text, re.I)
|
||||||
|
if maintained:
|
||||||
|
add(maintained.group(1))
|
||||||
|
add(maintained.group(2))
|
||||||
|
expression_stage = re.search(r"expression differences during (.+?)[.]", text, re.I)
|
||||||
|
if expression_stage:
|
||||||
|
add(expression_stage.group(1))
|
||||||
|
transformed = re.search(r"examined\s+in\s+(.+?)\s+transformed\s+(?:through|by)\s+(.+?)[.]", text, re.I)
|
||||||
|
if transformed:
|
||||||
|
add(transformed.group(1))
|
||||||
|
add(transformed.group(2))
|
||||||
|
for charged in re.findall(r"\b[A-Z][a-z]?\s*[+-]", text):
|
||||||
|
add(charged.replace(" ", ""))
|
||||||
|
return values
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _answer_type(text: str) -> str:
|
||||||
|
lowered = text.casefold()
|
||||||
|
if NaturalQuestionCompiler._answer_mode(text) == "open_ended_summary":
|
||||||
|
return "biological_process"
|
||||||
|
if "scientific name" in lowered or any(word in lowered for word in ("taxon", "class", "family or group")):
|
||||||
|
return "classification"
|
||||||
|
if "floral outcome" in lowered or any(word in lowered for word in ("phenotype", "outcome", "trait")):
|
||||||
|
return "phenotype"
|
||||||
|
if any(word in lowered for word in ("biological process", "pathway", "participate in")):
|
||||||
|
return "biological_process"
|
||||||
|
if "gene or protein" in lowered:
|
||||||
|
return "gene_or_protein"
|
||||||
|
if any(word in lowered for word in ("stress", "treatment", "hormone", "pathogen condition", "environmental condition")):
|
||||||
|
return "condition"
|
||||||
|
if any(word in lowered for word in ("where", "location", "compartment", "tissue", "organ")):
|
||||||
|
return "location"
|
||||||
|
if any(word in lowered for word in ("method", "approach", "tool")):
|
||||||
|
return "method"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _estimated_hops(text: str, clauses: list[str]) -> int:
|
||||||
|
if NaturalQuestionCompiler._answer_mode(text) == "open_ended_summary":
|
||||||
|
return 1
|
||||||
|
if re.match(r"^For\s+", text, re.I):
|
||||||
|
return 2
|
||||||
|
if re.match(r"^(?:which|what|where|when|in which)\b", text, re.I):
|
||||||
|
return 1
|
||||||
|
reported = len(re.findall(r"\bwas reported to\b", text, re.I))
|
||||||
|
if reported >= 2:
|
||||||
|
return min(4, reported + 1)
|
||||||
|
cues = re.findall(
|
||||||
|
r"\b(?:was reared|containing|maintained|used to estimate|reported to enhance|is homologous|operates|resolving|attributed|showed|was examined|transformed|used to introduce)\b",
|
||||||
|
text, re.I,
|
||||||
|
)
|
||||||
|
return max(3, min(4, max(len(clauses), len(cues))))
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.-]{2,}")
|
||||||
|
STOPWORDS = {
|
||||||
|
"what", "which", "where", "when", "with", "from", "that", "this",
|
||||||
|
"genes", "gene", "protein", "proteins", "reported", "associated",
|
||||||
|
"plant", "plants", "species", "family", "families",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PCIndependentRetriever:
|
||||||
|
"""Independent PC Qdrant + Neo4j HTTP retriever for QA V1.0.0."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.qdrant_url = os.environ.get(
|
||||||
|
"PC_QDRANT_URL",
|
||||||
|
os.environ.get("QDRANT_URL", "http://127.0.0.1:6333"),
|
||||||
|
)
|
||||||
|
self.collection = os.environ.get(
|
||||||
|
"PC_QDRANT_COLLECTION", "pc_srt_bge_full_20260620"
|
||||||
|
)
|
||||||
|
self.neo4j_url = os.environ.get(
|
||||||
|
"PC_NEO4J_HTTP_URL",
|
||||||
|
"http://127.0.0.1:7474/db/neo4j/tx/commit",
|
||||||
|
)
|
||||||
|
self.neo4j_user = os.environ.get("PC_NEO4J_USER", "neo4j")
|
||||||
|
self.neo4j_password = os.environ.get("PC_NEO4J_PASSWORD", "password")
|
||||||
|
self.embed_url = os.environ.get(
|
||||||
|
"QA_V1_EMBED_URL",
|
||||||
|
os.environ.get(
|
||||||
|
"V15_OLLAMA_EMBED_URL",
|
||||||
|
"http://127.0.0.1:11434/api/embed",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.embed_model = os.environ.get(
|
||||||
|
"QA_V1_EMBED_MODEL",
|
||||||
|
os.environ.get("V15_BGE_MODEL", "bge-m3:567m"),
|
||||||
|
)
|
||||||
|
self.qdrant = QdrantClient(url=self.qdrant_url, timeout=30)
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
top_k: int = 30,
|
||||||
|
max_hops: int = 2,
|
||||||
|
*,
|
||||||
|
use_qdrant: bool = True,
|
||||||
|
use_neo4j: bool = True,
|
||||||
|
use_graph_expansion: bool = True,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
vector = self.vector_search(question, top_k) if use_qdrant else []
|
||||||
|
graph = self.graph_search(question, top_k) if use_neo4j else []
|
||||||
|
expansion = (
|
||||||
|
self.expand_graph(graph[:8], top_k)
|
||||||
|
if use_neo4j and use_graph_expansion and max_hops >= 2
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return vector + graph + expansion
|
||||||
|
|
||||||
|
def embed(self, text: str) -> List[float]:
|
||||||
|
response = requests.post(
|
||||||
|
self.embed_url,
|
||||||
|
json={"model": self.embed_model, "input": text[:2400]},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
embeddings = response.json().get("embeddings") or []
|
||||||
|
if not embeddings:
|
||||||
|
raise RuntimeError("PC embedding endpoint returned no vector")
|
||||||
|
return embeddings[0]
|
||||||
|
|
||||||
|
def vector_search(self, question: str, limit: int) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
response = self.qdrant.query_points(
|
||||||
|
collection_name=self.collection,
|
||||||
|
query=self.embed(question),
|
||||||
|
limit=limit,
|
||||||
|
with_payload=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
output = []
|
||||||
|
for point in response.points:
|
||||||
|
row = dict(point.payload or {})
|
||||||
|
row["_vector_score"] = float(point.score or 0.0)
|
||||||
|
row["_retrieval_method"] = "pc_qdrant_bge"
|
||||||
|
output.append(row)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def graph_search(self, question: str, limit: int) -> List[Dict[str, Any]]:
|
||||||
|
terms = self.query_terms(question)
|
||||||
|
if not terms:
|
||||||
|
return []
|
||||||
|
statement = """
|
||||||
|
MATCH (e:PCEntity)
|
||||||
|
WHERE e.name_norm IN $terms
|
||||||
|
WITH e LIMIT 24
|
||||||
|
MATCH (e)-[r:PC_REL]-(n:PCEntity)
|
||||||
|
RETURN r.relation_id AS relation_id,
|
||||||
|
startNode(r).name AS source,
|
||||||
|
startNode(r).type AS source_type,
|
||||||
|
r.relation AS relation,
|
||||||
|
endNode(r).name AS target,
|
||||||
|
endNode(r).type AS target_type,
|
||||||
|
r.evidence AS evidence,
|
||||||
|
r.pmid AS pmid,
|
||||||
|
r.species AS species,
|
||||||
|
r.section AS section,
|
||||||
|
r.source_dataset AS source_dataset
|
||||||
|
ORDER BY CASE WHEN coalesce(r.evidence, '') = '' THEN 1 ELSE 0 END
|
||||||
|
LIMIT $limit
|
||||||
|
"""
|
||||||
|
rows = self.neo4j_rows(
|
||||||
|
statement, {"terms": terms, "limit": min(limit * 2, 100)}
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
row["_retrieval_method"] = "pc_neo4j_exact"
|
||||||
|
row["_vector_score"] = 1.0
|
||||||
|
return rows[:limit]
|
||||||
|
|
||||||
|
def expand_graph(
|
||||||
|
self, seeds: List[Dict[str, Any]], limit: int
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
names = []
|
||||||
|
for row in seeds:
|
||||||
|
for value in (row.get("source"), row.get("target")):
|
||||||
|
name = " ".join(str(value or "").lower().split())
|
||||||
|
if name and name not in names:
|
||||||
|
names.append(name)
|
||||||
|
if not names:
|
||||||
|
return []
|
||||||
|
statement = """
|
||||||
|
MATCH (e:PCEntity)
|
||||||
|
WHERE e.name_norm IN $names
|
||||||
|
WITH e LIMIT 10
|
||||||
|
MATCH (e)-[r:PC_REL]-(n:PCEntity)
|
||||||
|
RETURN r.relation_id AS relation_id,
|
||||||
|
startNode(r).name AS source,
|
||||||
|
startNode(r).type AS source_type,
|
||||||
|
r.relation AS relation,
|
||||||
|
endNode(r).name AS target,
|
||||||
|
endNode(r).type AS target_type,
|
||||||
|
r.evidence AS evidence,
|
||||||
|
r.pmid AS pmid,
|
||||||
|
r.species AS species,
|
||||||
|
r.section AS section,
|
||||||
|
r.source_dataset AS source_dataset
|
||||||
|
LIMIT $limit
|
||||||
|
"""
|
||||||
|
rows = self.neo4j_rows(
|
||||||
|
statement, {"names": names[:10], "limit": min(limit, 80)}
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
row["_retrieval_method"] = "pc_neo4j_expand"
|
||||||
|
row["_vector_score"] = 0.8
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def query_terms(question: str) -> List[str]:
|
||||||
|
terms = []
|
||||||
|
for token in TOKEN_RE.findall(question):
|
||||||
|
low = token.lower()
|
||||||
|
if len(low) >= 3 and low not in STOPWORDS:
|
||||||
|
terms.append(low)
|
||||||
|
return list(dict.fromkeys(terms))[:30]
|
||||||
|
|
||||||
|
def neo4j_rows(
|
||||||
|
self, statement: str, parameters: Dict[str, Any]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
self.neo4j_url,
|
||||||
|
auth=(self.neo4j_user, self.neo4j_password),
|
||||||
|
json={
|
||||||
|
"statements": [
|
||||||
|
{"statement": statement, "parameters": parameters}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
payload = response.json()
|
||||||
|
if payload.get("errors"):
|
||||||
|
return []
|
||||||
|
result = (payload.get("results") or [{}])[0]
|
||||||
|
columns = result.get("columns") or []
|
||||||
|
return [
|
||||||
|
dict(zip(columns, item.get("row") or []))
|
||||||
|
for item in result.get("data") or []
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .llm_client import LLMClient
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionalAnswerGenerator:
|
||||||
|
SYSTEM = """You are a plant biology expert.
|
||||||
|
|
||||||
|
Answer the question from your internal knowledge without using a supplied
|
||||||
|
knowledge base. Give a natural and reasonably complete provisional answer.
|
||||||
|
Use the same language as the question. Keep gene symbols and Latin species names unchanged.
|
||||||
|
Mention relevant genes, gene families, species, biological processes,
|
||||||
|
experimental operations, phenotypes, tissues, treatments, mechanisms, and
|
||||||
|
terminology when relevant.
|
||||||
|
|
||||||
|
This is a retrieval hypothesis, not a verified final answer. Do not include
|
||||||
|
citations and do not claim that you searched a database."""
|
||||||
|
|
||||||
|
def __init__(self, llm: LLMClient):
|
||||||
|
self.llm = llm
|
||||||
|
|
||||||
|
def generate(self, question: str) -> str:
|
||||||
|
return self.llm.complete(
|
||||||
|
self.SYSTEM,
|
||||||
|
f"Question:\n{question}",
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=1800,
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from .domain_config import DomainConfig
|
||||||
|
from .schemas import RetrievalHypothesis
|
||||||
|
|
||||||
|
|
||||||
|
class QueryBuilder:
|
||||||
|
def __init__(self, domain: DomainConfig, max_variants: int = 6):
|
||||||
|
self.domain = domain
|
||||||
|
self.max_variants = max_variants
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
provisional_answer: str,
|
||||||
|
hypothesis: RetrievalHypothesis,
|
||||||
|
) -> Dict[str, List[str]]:
|
||||||
|
direct_entities = [
|
||||||
|
entity.canonical_candidate
|
||||||
|
for entity in hypothesis.direct_question_entities
|
||||||
|
if entity.canonical_candidate
|
||||||
|
]
|
||||||
|
direct_mentions = self._unique(
|
||||||
|
[
|
||||||
|
entity.mention
|
||||||
|
for entity in hypothesis.direct_question_entities
|
||||||
|
if entity.mention
|
||||||
|
]
|
||||||
|
)
|
||||||
|
hypothesis_entities = [
|
||||||
|
entity.canonical_candidate
|
||||||
|
for entity in hypothesis.hypothesized_entities
|
||||||
|
if entity.canonical_candidate
|
||||||
|
]
|
||||||
|
relation_terms = self.domain.expanded_relations(
|
||||||
|
hypothesis.relation_intents
|
||||||
|
)
|
||||||
|
evidence_query_terms = self.domain.evidence_query_terms(
|
||||||
|
hypothesis.direct_question_entities
|
||||||
|
)
|
||||||
|
|
||||||
|
canonical_query = " ".join(direct_entities + relation_terms[:12])
|
||||||
|
evidence_query = " ".join(evidence_query_terms)
|
||||||
|
lane_a = [question]
|
||||||
|
if direct_mentions:
|
||||||
|
lane_a.append(" ".join(direct_mentions))
|
||||||
|
if canonical_query:
|
||||||
|
lane_a.append(canonical_query)
|
||||||
|
if evidence_query:
|
||||||
|
lane_a.append(evidence_query)
|
||||||
|
|
||||||
|
lane_b = [provisional_answer]
|
||||||
|
lane_b.extend(hypothesis.concept_queries)
|
||||||
|
if hypothesis_entities or relation_terms:
|
||||||
|
lane_b.append(" ".join(hypothesis_entities + relation_terms[:12]))
|
||||||
|
lane_b.extend(claim.text for claim in hypothesis.atomic_claims[:3])
|
||||||
|
|
||||||
|
question_lane = self._unique(lane_a)
|
||||||
|
if self.max_variants == 2 and canonical_query:
|
||||||
|
priority_lane = self._unique([question, canonical_query])
|
||||||
|
if len(priority_lane) == 2:
|
||||||
|
question_lane = priority_lane
|
||||||
|
|
||||||
|
return {
|
||||||
|
"question_lane": question_lane[: self.max_variants],
|
||||||
|
"hypothesis_lane": self._unique(lane_b)[: self.max_variants],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unique(values: List[str]) -> List[str]:
|
||||||
|
output: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
for value in values:
|
||||||
|
clean = " ".join(str(value or "").split())
|
||||||
|
key = clean.lower()
|
||||||
|
if clean and key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
output.append(clean)
|
||||||
|
return output
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from .agentic_controller import AgenticOutputError
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_VERSION = "question_decomposition_v1"
|
||||||
|
MAX_SUBQUESTIONS = 6
|
||||||
|
|
||||||
|
_TARGET_TYPE_ALIASES = {
|
||||||
|
"molecular_mechanism": "process",
|
||||||
|
"mechanism": "process",
|
||||||
|
"cellular_process": "process",
|
||||||
|
"protein_function": "biological_process",
|
||||||
|
"molecular_function": "biological_process",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
QUESTION_DECOMPOSITION_PROMPT = r"""You are the question-decomposition planner for a
|
||||||
|
plant-biology knowledge-base QA system. You plan retrieval; you never answer the
|
||||||
|
biological question and never invent facts, entities, relations, or citations.
|
||||||
|
|
||||||
|
Prompt version: question_decomposition_v1
|
||||||
|
|
||||||
|
Return exactly one JSON object and no Markdown.
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
- Convert the original question into independently retrievable answer slots.
|
||||||
|
- Every subquestion receives its own complete vector retrieval and native
|
||||||
|
knowledge-chain retrieval. The execution contract is vector_top_k=30.
|
||||||
|
- The complete Top30 is retained for audit, but only evidence accepted for each
|
||||||
|
subquestion may be handed to the final-answer LLM.
|
||||||
|
- Attempt 1 is the initial retrieval. Only PARTIAL or EMPTY subquestions may run
|
||||||
|
Attempt 2. Attempt 3 is forbidden.
|
||||||
|
|
||||||
|
Decomposition rules
|
||||||
|
1. Always create Q0. Q0 must preserve the original question verbatim (or, when
|
||||||
|
the caller later enforces it, exactly equal the supplied original question),
|
||||||
|
and it is always required. Q0 protects the direct intent from being lost.
|
||||||
|
2. Create supporting subquestions only when they fill distinct answer slots
|
||||||
|
needed for a fuller answer. Prefer 2-5 total questions; never exceed 6.
|
||||||
|
3. A subquestion must be independently retrievable and answerable from evidence.
|
||||||
|
Do not split mechanically by keywords or generate background trivia.
|
||||||
|
4. Preserve all constraints from the original question: species, gene family,
|
||||||
|
tissue, treatment, developmental stage, comparison, time, and requested scope.
|
||||||
|
5. Use only verified entities supplied by the caller. Do not introduce a new
|
||||||
|
species, gene family, gene, canonical ID, relation, or paper.
|
||||||
|
6. Distinguish start anchors, answer endpoints, and context constraints. Generic
|
||||||
|
words such as gene, gene family, function, structure, and pathway are normally
|
||||||
|
answer targets, not start anchors.
|
||||||
|
7. Enumeration or cross-entity questions require an explicit verified anchor,
|
||||||
|
compatible terminal type, correct assertion direction, and paper provenance.
|
||||||
|
For example, in a species-to-gene-family question the species is the start
|
||||||
|
anchor and gene_family is the answer endpoint; unrelated families from other
|
||||||
|
species are not acceptable substitutes.
|
||||||
|
8. Definition, naming, and structure questions may accept a complete structured
|
||||||
|
assertion (source, relation, target, evidence, stable ID, paper provenance)
|
||||||
|
without a graph path. Raw prose without those fields is insufficient.
|
||||||
|
9. Supporting questions must contribute directly to the final answer. Avoid
|
||||||
|
duplicate or paraphrased subquestions.
|
||||||
|
10. Do not decide that evidence exists. Only declare what retrieval and acceptance
|
||||||
|
criteria should be used.
|
||||||
|
11. Do not turn plausible biological knowledge into a retrieval premise. A
|
||||||
|
supporting question must not introduce a named pathway, subfamily, regulator,
|
||||||
|
organelle, or mechanism that is absent from both the original question and the
|
||||||
|
caller-supplied verified entities/relation intents. For a simple "how does X
|
||||||
|
help under Y" question, prefer Q0 plus one broad mechanism slot; retrieval,
|
||||||
|
not decomposition, must discover the individual mechanisms.
|
||||||
|
12. An endpoint-constrained connection question such as "what connects X with
|
||||||
|
Y, including intermediate entities" asks for a graph path, not necessarily a
|
||||||
|
molecular mechanism. Classify it as other unless the question explicitly
|
||||||
|
requests a molecular or physiological mechanism. Prefer Q0 alone because it
|
||||||
|
already contains both endpoints and the requested path; add a supporting
|
||||||
|
subquestion only when the original question contains a genuinely distinct
|
||||||
|
answer slot.
|
||||||
|
|
||||||
|
Fixed execution contract (the runtime will enforce these values)
|
||||||
|
- retrieval.vector_top_k: 30
|
||||||
|
- retrieval.retrieve_native_chains: true
|
||||||
|
- retrieval.max_hops: integer 1-3
|
||||||
|
- retrieval.allow_incoming and allow_outgoing: booleans
|
||||||
|
- retrieval.max_attempts: 2
|
||||||
|
- Q0 is first; IDs are continuous Q0, Q1, ...; synthesis_order uses those IDs.
|
||||||
|
|
||||||
|
Required JSON schema
|
||||||
|
{
|
||||||
|
"mode": "DECOMPOSE",
|
||||||
|
"prompt_version": "question_decomposition_v1",
|
||||||
|
"original_question": "verbatim original question",
|
||||||
|
"question_type": "enumeration|definition|naming|structure|function|mechanism|comparison|other",
|
||||||
|
"answer_goal": "one concise description of the desired final answer",
|
||||||
|
"global_constraints": {
|
||||||
|
"species": [],
|
||||||
|
"entities": [],
|
||||||
|
"scope": [],
|
||||||
|
"must_not_substitute_cross_species": true
|
||||||
|
},
|
||||||
|
"subquestions": [
|
||||||
|
{
|
||||||
|
"subquestion_id": "Q0",
|
||||||
|
"question": "verbatim original question",
|
||||||
|
"role": "required|supporting",
|
||||||
|
"answer_slot": "distinct information slot",
|
||||||
|
"anchors": [
|
||||||
|
{"mention": "", "canonical_name": "", "canonical_id": "", "entity_type": "", "role": "start_anchor|context_constraint"}
|
||||||
|
],
|
||||||
|
"target_entity_types": ["unknown"],
|
||||||
|
"relation_intents": [],
|
||||||
|
"constraints": [],
|
||||||
|
"retrieval": {
|
||||||
|
"vector_top_k": 30,
|
||||||
|
"retrieve_native_chains": true,
|
||||||
|
"max_hops": 2,
|
||||||
|
"allow_incoming": true,
|
||||||
|
"allow_outgoing": true,
|
||||||
|
"max_attempts": 2
|
||||||
|
},
|
||||||
|
"acceptance_requirements": {
|
||||||
|
"requires_anchor_connection": false,
|
||||||
|
"requires_terminal_type_match": true,
|
||||||
|
"requires_structured_assertion": true,
|
||||||
|
"requires_paper_provenance": true
|
||||||
|
},
|
||||||
|
"contribution_to_final_answer": "how this slot will be used"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"synthesis_order": ["Q0"],
|
||||||
|
"decomposition_summary": "why these slots are sufficient and non-duplicative"
|
||||||
|
}
|
||||||
|
|
||||||
|
Example 1 — naming question
|
||||||
|
Input: R2R3-MYB蛋白为什么被称为R2R3-MYB?
|
||||||
|
Plan: retain Q0 as the exact question; add at most one supporting slot for the
|
||||||
|
repeat/domain composition only if it is distinct. Use the verified R2R3-MYB
|
||||||
|
entity, target structure/domain assertions, and allow complete structured
|
||||||
|
assertions with provenance even when no graph path exists. Do not add MYB
|
||||||
|
functions, evolution, or unrelated species as background.
|
||||||
|
|
||||||
|
Example 2 — anchored enumeration
|
||||||
|
Input: 荔枝中有哪些基因家族已经被研究?
|
||||||
|
Verified species: Litchi chinensis
|
||||||
|
Plan: Q0 remains the exact enumeration question. Use Litchi chinensis as the
|
||||||
|
required start anchor, gene_family as the endpoint type, search both assertion
|
||||||
|
directions, and accept only provenance-bearing evidence that connects the
|
||||||
|
species to the reported family. A useful supporting slot may verify the direct
|
||||||
|
species-study-family relation, but must not enumerate families merely studied in
|
||||||
|
Arabidopsis, rice, tomato, or other species.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_text(values: Any, *, limit: int = 30) -> List[str]:
|
||||||
|
if not isinstance(values, (list, tuple)):
|
||||||
|
return []
|
||||||
|
output: List[str] = []
|
||||||
|
for value in values:
|
||||||
|
item = _text(value)
|
||||||
|
if item and item not in output:
|
||||||
|
output.append(item)
|
||||||
|
if len(output) >= limit:
|
||||||
|
break
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_int(value: Any, default: int, minimum: int, maximum: int) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
parsed = default
|
||||||
|
return max(minimum, min(parsed, maximum))
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionDecompositionPlanner:
|
||||||
|
"""Generate and deterministically constrain a multi-question retrieval plan."""
|
||||||
|
|
||||||
|
def __init__(self, llm: Any):
|
||||||
|
self.llm = llm
|
||||||
|
|
||||||
|
def decompose(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
*,
|
||||||
|
verified_entities: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
relation_intents: Optional[List[str]] = None,
|
||||||
|
answer_slots: Optional[List[str]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
question = _text(question)
|
||||||
|
entities = [item for item in (verified_entities or []) if isinstance(item, dict)]
|
||||||
|
relations = _dedupe_text(relation_intents or [])
|
||||||
|
slots = _dedupe_text(answer_slots or [])
|
||||||
|
fallback = self._fallback_plan(question, entities, relations, slots)
|
||||||
|
payload = {
|
||||||
|
"mode": "DECOMPOSE",
|
||||||
|
"original_question": question,
|
||||||
|
"verified_entities": entities,
|
||||||
|
"relation_intents": relations,
|
||||||
|
"answer_slots": slots,
|
||||||
|
"capabilities": {
|
||||||
|
"vector_top_k": 30,
|
||||||
|
"retrieve_native_chains": True,
|
||||||
|
"max_hops": 3,
|
||||||
|
"max_attempts_per_subquestion": 2,
|
||||||
|
"max_subquestions": MAX_SUBQUESTIONS,
|
||||||
|
"retain_complete_top30_for_audit": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
raw = self.llm.complete_json(
|
||||||
|
QUESTION_DECOMPOSITION_PROMPT,
|
||||||
|
json.dumps(payload, ensure_ascii=False),
|
||||||
|
max_tokens=16000,
|
||||||
|
)
|
||||||
|
json_repair_used = False
|
||||||
|
if not isinstance(raw, dict) or raw.get("_json_parse_error"):
|
||||||
|
json_repair_used = True
|
||||||
|
raw_head = (
|
||||||
|
_text(raw.get("_raw_text_head"))
|
||||||
|
if isinstance(raw, dict)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
repair_payload = {
|
||||||
|
**payload,
|
||||||
|
"format_repair": (
|
||||||
|
"The previous DECOMPOSE response was empty, incomplete, or "
|
||||||
|
"malformed. Return one complete compact JSON object matching "
|
||||||
|
"the required decomposition schema."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
raw = self.llm.complete_json(
|
||||||
|
QUESTION_DECOMPOSITION_PROMPT
|
||||||
|
+ "\nThis is a single format-repair retry. Close every object and "
|
||||||
|
"array. Return the compact DECOMPOSE JSON object only.",
|
||||||
|
json.dumps(repair_payload, ensure_ascii=False),
|
||||||
|
max_tokens=16000,
|
||||||
|
)
|
||||||
|
if not isinstance(raw, dict) or raw.get("_json_parse_error"):
|
||||||
|
raw_head = (
|
||||||
|
_text(raw.get("_raw_text_head"))
|
||||||
|
if isinstance(raw, dict)
|
||||||
|
else raw_head
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"question decomposition is not valid JSON"
|
||||||
|
+ (f"; raw_head={raw_head[:300]!r}" if raw_head else "; raw_head=<empty>")
|
||||||
|
)
|
||||||
|
plan = self._sanitize(raw, fallback, question, entities, relations)
|
||||||
|
plan["plan_source"] = (
|
||||||
|
"llm_agent_json_repair" if json_repair_used else "llm_agent"
|
||||||
|
)
|
||||||
|
plan["plan_json_attempts"] = 2 if json_repair_used else 1
|
||||||
|
return plan
|
||||||
|
except Exception as exc:
|
||||||
|
recovered = deepcopy(fallback)
|
||||||
|
recovered["plan_source"] = (
|
||||||
|
"deterministic_recovery_after_llm_decomposition_failure"
|
||||||
|
)
|
||||||
|
recovered["plan_json_attempts"] = 0
|
||||||
|
recovered["recovery_error"] = (
|
||||||
|
f"{type(exc).__name__}: {exc}"
|
||||||
|
)[:1500]
|
||||||
|
recovered["recovery_degraded"] = True
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
def _sanitize(
|
||||||
|
self,
|
||||||
|
raw: Dict[str, Any],
|
||||||
|
fallback: Dict[str, Any],
|
||||||
|
question: str,
|
||||||
|
verified_entities: List[Dict[str, Any]],
|
||||||
|
allowed_relation_intents: List[str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
raw_items = raw.get("subquestions")
|
||||||
|
if not isinstance(raw_items, list) or not raw_items:
|
||||||
|
raise ValueError("decomposition contains no subquestions")
|
||||||
|
|
||||||
|
question_type = self._question_type(raw.get("question_type"))
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
seen_questions = set()
|
||||||
|
for raw_item in raw_items:
|
||||||
|
if not isinstance(raw_item, dict):
|
||||||
|
continue
|
||||||
|
index = len(items)
|
||||||
|
subquestion = question if index == 0 else _text(raw_item.get("question"))
|
||||||
|
normalized = " ".join(subquestion.lower().split())
|
||||||
|
if not subquestion or normalized in seen_questions:
|
||||||
|
continue
|
||||||
|
if index > 0 and question_type == "mechanism":
|
||||||
|
if any(
|
||||||
|
marker in subquestion.lower()
|
||||||
|
for marker in ("哪些", "有哪些", "列出", "which genes", "list ")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
proposed_relations = _dedupe_text(raw_item.get("relation_intents") or [])
|
||||||
|
if allowed_relation_intents and any(
|
||||||
|
value not in allowed_relation_intents
|
||||||
|
for value in proposed_relations
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if self._introduces_unverified_uppercase_entity(
|
||||||
|
subquestion, question, verified_entities
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
seen_questions.add(normalized)
|
||||||
|
item = self._sanitize_subquestion(raw_item, index, subquestion)
|
||||||
|
item["anchors"] = self._verified_anchors(
|
||||||
|
item["anchors"], verified_entities
|
||||||
|
)
|
||||||
|
if not item["anchors"]:
|
||||||
|
item["anchors"] = self._implicit_verified_anchors(
|
||||||
|
subquestion, verified_entities
|
||||||
|
)
|
||||||
|
item["relation_intents"] = [
|
||||||
|
value
|
||||||
|
for value in item["relation_intents"]
|
||||||
|
if value in allowed_relation_intents
|
||||||
|
]
|
||||||
|
if index == 0:
|
||||||
|
if not item["anchors"]:
|
||||||
|
item["anchors"] = deepcopy(fallback["subquestions"][0]["anchors"])
|
||||||
|
if not item["relation_intents"]:
|
||||||
|
item["relation_intents"] = list(allowed_relation_intents)
|
||||||
|
items.append(item)
|
||||||
|
if len(items) >= MAX_SUBQUESTIONS:
|
||||||
|
break
|
||||||
|
if not items:
|
||||||
|
raise ValueError("decomposition contains no usable subquestions")
|
||||||
|
|
||||||
|
plan = deepcopy(fallback)
|
||||||
|
plan.update(
|
||||||
|
{
|
||||||
|
"mode": "DECOMPOSE",
|
||||||
|
"prompt_version": PROMPT_VERSION,
|
||||||
|
"original_question": question,
|
||||||
|
"question_type": question_type,
|
||||||
|
"answer_goal": _text(raw.get("answer_goal"))[:1000]
|
||||||
|
or fallback["answer_goal"],
|
||||||
|
"global_constraints": self._global_constraints(
|
||||||
|
raw.get("global_constraints"), fallback["global_constraints"]
|
||||||
|
),
|
||||||
|
"subquestions": items,
|
||||||
|
"synthesis_order": [item["subquestion_id"] for item in items],
|
||||||
|
"decomposition_summary": _text(raw.get("decomposition_summary"))[:1500],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_mechanism_support(
|
||||||
|
verified_entities: List[Dict[str, Any]],
|
||||||
|
allowed_relation_intents: List[str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
focus = next(
|
||||||
|
(
|
||||||
|
_text(entity.get("mention")) or _text(entity.get("canonical_name"))
|
||||||
|
for entity in verified_entities
|
||||||
|
if any(
|
||||||
|
marker in _text(entity.get("entity_type")).lower()
|
||||||
|
for marker in ("gene", "protein", "family")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"该主体",
|
||||||
|
)
|
||||||
|
context = next(
|
||||||
|
(
|
||||||
|
_text(entity.get("mention")) or _text(entity.get("canonical_name"))
|
||||||
|
for entity in verified_entities
|
||||||
|
if any(
|
||||||
|
marker in _text(entity.get("entity_type")).lower()
|
||||||
|
for marker in ("condition", "stress", "treatment")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"题目所述条件",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"question": (
|
||||||
|
f"{focus}在{context}条件下的直接分子作用、被保护对象和"
|
||||||
|
"生物学结果分别是什么?"
|
||||||
|
),
|
||||||
|
"role": "supporting",
|
||||||
|
"answer_slot": (
|
||||||
|
"direct molecular action, protected target or process, and supported outcome"
|
||||||
|
),
|
||||||
|
"anchors": [],
|
||||||
|
"target_entity_types": [
|
||||||
|
"process",
|
||||||
|
"biological_process",
|
||||||
|
"protein",
|
||||||
|
"trait",
|
||||||
|
],
|
||||||
|
"relation_intents": list(allowed_relation_intents),
|
||||||
|
"constraints": [],
|
||||||
|
"retrieval": {
|
||||||
|
"vector_top_k": 30,
|
||||||
|
"retrieve_native_chains": True,
|
||||||
|
"max_hops": 1,
|
||||||
|
"allow_incoming": True,
|
||||||
|
"allow_outgoing": True,
|
||||||
|
"max_attempts": 2,
|
||||||
|
},
|
||||||
|
"acceptance_requirements": {
|
||||||
|
"requires_anchor_connection": True,
|
||||||
|
"requires_terminal_type_match": True,
|
||||||
|
"requires_structured_assertion": True,
|
||||||
|
"requires_paper_provenance": True,
|
||||||
|
},
|
||||||
|
"contribution_to_final_answer": (
|
||||||
|
"Fill the mechanism chain without presupposing a named pathway or factor."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _introduces_unverified_uppercase_entity(
|
||||||
|
subquestion: str,
|
||||||
|
original_question: str,
|
||||||
|
verified_entities: List[Dict[str, Any]],
|
||||||
|
) -> bool:
|
||||||
|
token_pattern = r"(?<![A-Za-z0-9])([A-Z][A-Z0-9-]{1,12})(?![A-Za-z0-9])"
|
||||||
|
allowed_text = " ".join(
|
||||||
|
[original_question]
|
||||||
|
+ [
|
||||||
|
_text(entity.get(key))
|
||||||
|
for entity in verified_entities
|
||||||
|
for key in ("mention", "canonical_name")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
allowed_tokens = set(re.findall(token_pattern, allowed_text))
|
||||||
|
proposed_tokens = set(re.findall(token_pattern, subquestion))
|
||||||
|
return bool(proposed_tokens - allowed_tokens)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verified_anchors(
|
||||||
|
proposed: List[Dict[str, Any]],
|
||||||
|
verified_entities: List[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
lookup: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for entity in verified_entities:
|
||||||
|
for key in ("mention", "canonical_name", "canonical_id"):
|
||||||
|
marker = _text(entity.get(key)).casefold()
|
||||||
|
if marker:
|
||||||
|
lookup[marker] = entity
|
||||||
|
|
||||||
|
output: List[Dict[str, str]] = []
|
||||||
|
seen = set()
|
||||||
|
for anchor in proposed:
|
||||||
|
entity = None
|
||||||
|
for key in ("canonical_id", "canonical_name", "mention"):
|
||||||
|
marker = _text(anchor.get(key)).casefold()
|
||||||
|
if marker and marker in lookup:
|
||||||
|
entity = lookup[marker]
|
||||||
|
break
|
||||||
|
if entity is None:
|
||||||
|
continue
|
||||||
|
identity = (
|
||||||
|
_text(entity.get("canonical_id"))
|
||||||
|
or _text(entity.get("canonical_name"))
|
||||||
|
or _text(entity.get("mention"))
|
||||||
|
).casefold()
|
||||||
|
if identity in seen:
|
||||||
|
continue
|
||||||
|
seen.add(identity)
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
"mention": _text(entity.get("mention")),
|
||||||
|
"canonical_name": _text(entity.get("canonical_name")),
|
||||||
|
"canonical_id": _text(entity.get("canonical_id")),
|
||||||
|
"entity_type": _text(entity.get("entity_type")),
|
||||||
|
"role": anchor["role"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _implicit_verified_anchors(
|
||||||
|
question: str,
|
||||||
|
verified_entities: List[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
question_folded = question.casefold()
|
||||||
|
uppercase_tokens = set(
|
||||||
|
re.findall(
|
||||||
|
r"(?<![A-Za-z0-9])([A-Z][A-Z0-9-]{1,12})(?![A-Za-z0-9])",
|
||||||
|
question,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output = []
|
||||||
|
for entity in verified_entities:
|
||||||
|
mention = _text(entity.get("mention"))
|
||||||
|
canonical_name = _text(entity.get("canonical_name"))
|
||||||
|
literal_match = any(
|
||||||
|
value and value.casefold() in question_folded
|
||||||
|
for value in (mention, canonical_name)
|
||||||
|
)
|
||||||
|
canonical_tokens = set(
|
||||||
|
re.findall(
|
||||||
|
r"(?<![A-Za-z0-9])([A-Z][A-Z0-9-]{1,12})(?![A-Za-z0-9])",
|
||||||
|
canonical_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not literal_match and not (uppercase_tokens & canonical_tokens):
|
||||||
|
continue
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
"mention": mention,
|
||||||
|
"canonical_name": canonical_name,
|
||||||
|
"canonical_id": _text(entity.get("canonical_id")),
|
||||||
|
"entity_type": _text(entity.get("entity_type")),
|
||||||
|
"role": "start_anchor",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_subquestion(raw: Dict[str, Any], index: int, question: str) -> Dict[str, Any]:
|
||||||
|
retrieval = raw.get("retrieval") if isinstance(raw.get("retrieval"), dict) else {}
|
||||||
|
acceptance = (
|
||||||
|
raw.get("acceptance_requirements")
|
||||||
|
if isinstance(raw.get("acceptance_requirements"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
anchors: List[Dict[str, str]] = []
|
||||||
|
for value in raw.get("anchors") or []:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
anchor = {
|
||||||
|
"mention": _text(value.get("mention")),
|
||||||
|
"canonical_name": _text(value.get("canonical_name")),
|
||||||
|
"canonical_id": _text(value.get("canonical_id")),
|
||||||
|
"entity_type": _text(value.get("entity_type")),
|
||||||
|
"role": _text(value.get("role"))
|
||||||
|
if _text(value.get("role")) in {"start_anchor", "context_constraint"}
|
||||||
|
else "start_anchor",
|
||||||
|
}
|
||||||
|
if anchor["mention"] or anchor["canonical_name"] or anchor["canonical_id"]:
|
||||||
|
anchors.append(anchor)
|
||||||
|
if len(anchors) >= 20:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"subquestion_id": f"Q{index}",
|
||||||
|
"question": question,
|
||||||
|
"role": "required" if index == 0 else (
|
||||||
|
"required" if _text(raw.get("role")) == "required" else "supporting"
|
||||||
|
),
|
||||||
|
"answer_slot": _text(raw.get("answer_slot"))[:500]
|
||||||
|
or ("direct answer to original question" if index == 0 else f"supporting slot {index}"),
|
||||||
|
"anchors": anchors,
|
||||||
|
"target_entity_types": QuestionDecompositionPlanner._normalize_target_types(
|
||||||
|
raw.get("target_entity_types")
|
||||||
|
)
|
||||||
|
or ["unknown"],
|
||||||
|
"relation_intents": _dedupe_text(raw.get("relation_intents")),
|
||||||
|
"constraints": _dedupe_text(raw.get("constraints")),
|
||||||
|
"retrieval": {
|
||||||
|
"vector_top_k": 30,
|
||||||
|
"retrieve_native_chains": True,
|
||||||
|
"max_hops": _bounded_int(retrieval.get("max_hops"), 2, 1, 3),
|
||||||
|
"allow_incoming": bool(retrieval.get("allow_incoming", True)),
|
||||||
|
"allow_outgoing": bool(retrieval.get("allow_outgoing", True)),
|
||||||
|
"max_attempts": 2,
|
||||||
|
},
|
||||||
|
"acceptance_requirements": {
|
||||||
|
"requires_anchor_connection": bool(
|
||||||
|
acceptance.get("requires_anchor_connection", bool(anchors))
|
||||||
|
),
|
||||||
|
"requires_terminal_type_match": bool(
|
||||||
|
acceptance.get("requires_terminal_type_match", True)
|
||||||
|
),
|
||||||
|
"requires_structured_assertion": True,
|
||||||
|
"requires_paper_provenance": True,
|
||||||
|
},
|
||||||
|
"contribution_to_final_answer": _text(
|
||||||
|
raw.get("contribution_to_final_answer")
|
||||||
|
)[:1000],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _question_type(value: Any) -> str:
|
||||||
|
normalized = _text(value).lower()
|
||||||
|
allowed = {
|
||||||
|
"enumeration",
|
||||||
|
"definition",
|
||||||
|
"naming",
|
||||||
|
"structure",
|
||||||
|
"function",
|
||||||
|
"mechanism",
|
||||||
|
"comparison",
|
||||||
|
"other",
|
||||||
|
}
|
||||||
|
return normalized if normalized in allowed else "other"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_target_types(values: Any) -> List[str]:
|
||||||
|
output = []
|
||||||
|
for value in _dedupe_text(values):
|
||||||
|
normalized = "_".join(value.casefold().replace("-", " ").split())
|
||||||
|
mapped = _TARGET_TYPE_ALIASES.get(normalized, normalized)
|
||||||
|
if mapped and mapped not in output:
|
||||||
|
output.append(mapped)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _global_constraints(value: Any, fallback: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
raw = value if isinstance(value, dict) else {}
|
||||||
|
return {
|
||||||
|
"species": _dedupe_text(raw.get("species")),
|
||||||
|
"entities": _dedupe_text(raw.get("entities")),
|
||||||
|
"scope": _dedupe_text(raw.get("scope")),
|
||||||
|
"must_not_substitute_cross_species": bool(
|
||||||
|
raw.get("must_not_substitute_cross_species", True)
|
||||||
|
),
|
||||||
|
**{
|
||||||
|
key: deepcopy(item)
|
||||||
|
for key, item in fallback.items()
|
||||||
|
if key not in {"species", "entities", "scope", "must_not_substitute_cross_species"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fallback_plan(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
entities: List[Dict[str, Any]],
|
||||||
|
relation_intents: List[str],
|
||||||
|
answer_slots: List[str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
anchors = []
|
||||||
|
for value in entities[:20]:
|
||||||
|
anchor = {
|
||||||
|
"mention": _text(value.get("mention")),
|
||||||
|
"canonical_name": _text(value.get("canonical_name")),
|
||||||
|
"canonical_id": _text(value.get("canonical_id")),
|
||||||
|
"entity_type": _text(value.get("entity_type")),
|
||||||
|
"role": "start_anchor",
|
||||||
|
}
|
||||||
|
if anchor["mention"] or anchor["canonical_name"] or anchor["canonical_id"]:
|
||||||
|
anchors.append(anchor)
|
||||||
|
raw_q0 = {
|
||||||
|
"answer_slot": answer_slots[0] if answer_slots else "direct answer to original question",
|
||||||
|
"anchors": anchors,
|
||||||
|
"target_entity_types": ["unknown"],
|
||||||
|
"relation_intents": relation_intents,
|
||||||
|
"retrieval": {},
|
||||||
|
"acceptance_requirements": {
|
||||||
|
"requires_anchor_connection": bool(anchors),
|
||||||
|
},
|
||||||
|
"contribution_to_final_answer": "Directly answer the original question",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"mode": "DECOMPOSE",
|
||||||
|
"prompt_version": PROMPT_VERSION,
|
||||||
|
"original_question": question,
|
||||||
|
"question_type": "other",
|
||||||
|
"answer_goal": answer_slots[0] if answer_slots else "Answer the original question with evidence",
|
||||||
|
"global_constraints": {
|
||||||
|
"species": [],
|
||||||
|
"entities": [],
|
||||||
|
"scope": [],
|
||||||
|
"must_not_substitute_cross_species": True,
|
||||||
|
},
|
||||||
|
"subquestions": [self._sanitize_subquestion(raw_q0, 0, question)],
|
||||||
|
"synthesis_order": ["Q0"],
|
||||||
|
"decomposition_summary": "Deterministic atomic fallback; no unsupported split was introduced.",
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
from .schemas import EvidenceItem
|
||||||
|
|
||||||
|
|
||||||
|
class BGEReranker:
|
||||||
|
def __init__(self, model_path: Path, enabled: bool = True):
|
||||||
|
self.model_path = model_path
|
||||||
|
self.enabled = enabled
|
||||||
|
self._tokenizer = None
|
||||||
|
self._model = None
|
||||||
|
self._torch = None
|
||||||
|
self._device = "cpu"
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if self._model is not None or not self.enabled:
|
||||||
|
return
|
||||||
|
import os
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||||
|
|
||||||
|
device_pref = os.environ.get("QA_V1_BGE_DEVICE", "cuda").lower()
|
||||||
|
self._device = (
|
||||||
|
"cuda"
|
||||||
|
if torch.cuda.is_available() and device_pref in {"cuda", "gpu"}
|
||||||
|
else "cpu"
|
||||||
|
)
|
||||||
|
self._torch = torch
|
||||||
|
self._tokenizer = AutoTokenizer.from_pretrained(
|
||||||
|
str(self.model_path), local_files_only=True
|
||||||
|
)
|
||||||
|
self._model = AutoModelForSequenceClassification.from_pretrained(
|
||||||
|
str(self.model_path), local_files_only=True
|
||||||
|
)
|
||||||
|
self._model.to(self._device)
|
||||||
|
self._model.eval()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _document(item: EvidenceItem) -> str:
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
f"Source: {item.source}",
|
||||||
|
f"Relation: {item.relation}",
|
||||||
|
f"Target: {item.target}",
|
||||||
|
f"Species: {item.species}",
|
||||||
|
f"Layer: {item.output_layer}",
|
||||||
|
f"Evidence: {item.evidence}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def rerank(
|
||||||
|
self, question: str, items: List[EvidenceItem]
|
||||||
|
) -> Tuple[List[EvidenceItem], List[Dict]]:
|
||||||
|
if not items:
|
||||||
|
return [], []
|
||||||
|
if not self.enabled:
|
||||||
|
for item in items:
|
||||||
|
item.final_score = item.rrf_score
|
||||||
|
return items, [{"status": "disabled"}]
|
||||||
|
self._load()
|
||||||
|
documents = [self._document(item) for item in items]
|
||||||
|
raw_scores: List[float] = []
|
||||||
|
assert self._torch is not None
|
||||||
|
assert self._tokenizer is not None
|
||||||
|
assert self._model is not None
|
||||||
|
with self._torch.no_grad():
|
||||||
|
for start in range(0, len(documents), 16):
|
||||||
|
pairs = [
|
||||||
|
[question, document]
|
||||||
|
for document in documents[start : start + 16]
|
||||||
|
]
|
||||||
|
inputs = self._tokenizer(
|
||||||
|
pairs,
|
||||||
|
padding=True,
|
||||||
|
truncation=True,
|
||||||
|
max_length=512,
|
||||||
|
return_tensors="pt",
|
||||||
|
)
|
||||||
|
inputs = {key: value.to(self._device) for key, value in inputs.items()}
|
||||||
|
logits = self._model(**inputs).logits.reshape(-1)
|
||||||
|
raw_scores.extend(logits.detach().float().cpu().tolist())
|
||||||
|
max_rrf = max((item.rrf_score for item in items), default=1.0) or 1.0
|
||||||
|
for item, raw_score in zip(items, raw_scores):
|
||||||
|
try:
|
||||||
|
bge = 1.0 / (1.0 + math.exp(-float(raw_score)))
|
||||||
|
except OverflowError:
|
||||||
|
bge = 0.0 if raw_score < 0 else 1.0
|
||||||
|
item.rerank_score = bge
|
||||||
|
item.final_score = 0.80 * bge + 0.20 * (item.rrf_score / max_rrf)
|
||||||
|
items.sort(key=lambda item: -item.final_score)
|
||||||
|
return items, [
|
||||||
|
{
|
||||||
|
"rank": rank,
|
||||||
|
"evidence_id": item.evidence_id,
|
||||||
|
"source": item.source,
|
||||||
|
"relation": item.relation,
|
||||||
|
"target": item.target,
|
||||||
|
"rrf_score": item.rrf_score,
|
||||||
|
"bge_score": item.rerank_score,
|
||||||
|
"final_score": item.final_score,
|
||||||
|
}
|
||||||
|
for rank, item in enumerate(items, 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def adaptive_select(
|
||||||
|
items: List[EvidenceItem], minimum: int, maximum: int
|
||||||
|
) -> List[EvidenceItem]:
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
maximum = min(maximum, len(items))
|
||||||
|
minimum = min(minimum, maximum)
|
||||||
|
best = max(item.final_score for item in items) or 1.0
|
||||||
|
selected = [
|
||||||
|
item for item in items[:maximum] if item.final_score >= best * 0.35
|
||||||
|
]
|
||||||
|
if len(selected) < minimum:
|
||||||
|
selected = items[:minimum]
|
||||||
|
return selected[:maximum]
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
from .schemas import EvidenceItem
|
||||||
|
from .v16_retriever import V16IndependentRetriever
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_RETRIEVAL_OPTIONS: Dict[str, bool] = {
|
||||||
|
"use_v15": True,
|
||||||
|
"use_pc": False,
|
||||||
|
"use_sqlite": False,
|
||||||
|
"use_qdrant": True,
|
||||||
|
"use_neo4j": True,
|
||||||
|
"use_graph_expansion": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_retrieval_options(options: Dict[str, Any] | None) -> Dict[str, bool]:
|
||||||
|
normalized = dict(DEFAULT_RETRIEVAL_OPTIONS)
|
||||||
|
if isinstance(options, dict):
|
||||||
|
if options.get("use_pc") or options.get("use_sqlite"):
|
||||||
|
raise ValueError(
|
||||||
|
"PC and SQLite retrieval backends are disabled; use Neo4j and Qdrant"
|
||||||
|
)
|
||||||
|
for key in list(normalized):
|
||||||
|
if key in options:
|
||||||
|
normalized[key] = bool(options[key])
|
||||||
|
# If Neo4j is disabled, expansion cannot run.
|
||||||
|
if not normalized["use_neo4j"]:
|
||||||
|
normalized["use_graph_expansion"] = False
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class DualKBRetriever:
|
||||||
|
"""Use stable public KB clients without importing previous QA server scripts."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.v15 = V16IndependentRetriever()
|
||||||
|
|
||||||
|
def retrieve_queries(
|
||||||
|
self,
|
||||||
|
queries: Dict[str, List[str]],
|
||||||
|
*,
|
||||||
|
top_k_per_query: int,
|
||||||
|
graph_hops: int,
|
||||||
|
include_candidate: bool,
|
||||||
|
retrieval_options: Dict[str, Any] | None = None,
|
||||||
|
) -> Tuple[List[EvidenceItem], List[Dict[str, Any]]]:
|
||||||
|
options = normalize_retrieval_options(retrieval_options)
|
||||||
|
output: List[EvidenceItem] = []
|
||||||
|
audit: List[Dict[str, Any]] = []
|
||||||
|
for lane, lane_queries in queries.items():
|
||||||
|
for query in lane_queries:
|
||||||
|
before = len(output)
|
||||||
|
if options["use_v15"]:
|
||||||
|
output.extend(
|
||||||
|
self._retrieve_v15(
|
||||||
|
query,
|
||||||
|
lane,
|
||||||
|
top_k_per_query,
|
||||||
|
graph_hops,
|
||||||
|
include_candidate,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
audit.append(
|
||||||
|
{
|
||||||
|
"lane": lane,
|
||||||
|
"query": query,
|
||||||
|
"new_rows": len(output) - before,
|
||||||
|
"retrieval_options": dict(options),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return output, audit
|
||||||
|
|
||||||
|
def _retrieve_v15(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
lane: str,
|
||||||
|
top_k: int,
|
||||||
|
graph_hops: int,
|
||||||
|
include_candidate: bool,
|
||||||
|
options: Dict[str, bool],
|
||||||
|
) -> List[EvidenceItem]:
|
||||||
|
items: List[EvidenceItem] = []
|
||||||
|
try:
|
||||||
|
rows = self.v15.search(
|
||||||
|
query,
|
||||||
|
top_k=top_k,
|
||||||
|
max_hops=graph_hops,
|
||||||
|
include_candidate=include_candidate,
|
||||||
|
use_sqlite=False,
|
||||||
|
use_qdrant=options["use_qdrant"],
|
||||||
|
use_neo4j=options["use_neo4j"],
|
||||||
|
use_graph_expansion=options["use_graph_expansion"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
for row in rows:
|
||||||
|
method = str(row.get("_retrieval_method") or "v16_hybrid")
|
||||||
|
items.append(self._from_row(row, "V16/V15", f"{lane}:{method}", query))
|
||||||
|
return items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _from_row(row: Dict[str, Any], kb: str, method: str, query: str) -> EvidenceItem:
|
||||||
|
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
|
||||||
|
return EvidenceItem(
|
||||||
|
evidence_id="",
|
||||||
|
kb=kb,
|
||||||
|
source=_text(row.get("source") or metadata.get("source")),
|
||||||
|
relation=_text(row.get("relation") or metadata.get("relation")),
|
||||||
|
target=_text(row.get("target") or metadata.get("target")),
|
||||||
|
evidence=_text(row.get("evidence") or row.get("content")),
|
||||||
|
paper_id=_text(row.get("paper_id") or row.get("canonical_paper_id") or metadata.get("paper_id")),
|
||||||
|
title=_text(row.get("title") or row.get("paper_title") or metadata.get("title")),
|
||||||
|
pmid=_text(row.get("pmid") or row.get("pubmed_id") or metadata.get("pmid")),
|
||||||
|
pmcid=_text(row.get("pmcid") or metadata.get("pmcid")),
|
||||||
|
doi=_text(row.get("doi") or metadata.get("doi")),
|
||||||
|
species=_text(row.get("species") or metadata.get("species")),
|
||||||
|
section=_text(row.get("section") or metadata.get("section")),
|
||||||
|
output_layer=_text(row.get("output_layer") or metadata.get("output_layer")),
|
||||||
|
claim_scope=_text(row.get("claim_scope") or metadata.get("claim_scope")),
|
||||||
|
relation_id=_text(row.get("relation_id") or metadata.get("relation_id")),
|
||||||
|
retrieval_methods=[method],
|
||||||
|
retrieval_queries=[query],
|
||||||
|
vector_score=float(row.get("_vector_score") or row.get("score") or 0.0),
|
||||||
|
raw=dict(row),
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.v15.close()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HypothesisEntity:
|
||||||
|
mention: str
|
||||||
|
canonical_candidate: str
|
||||||
|
entity_type: str = "unknown"
|
||||||
|
origin: str = "provisional_answer"
|
||||||
|
confidence: float = 0.0
|
||||||
|
verification_status: str = "unverified"
|
||||||
|
matched_kb_entity: str = ""
|
||||||
|
use_as_graph_seed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AtomicClaim:
|
||||||
|
claim_id: str
|
||||||
|
text: str
|
||||||
|
entities: List[str] = field(default_factory=list)
|
||||||
|
relation_hint: str = ""
|
||||||
|
verification_status: str = "unverified"
|
||||||
|
supporting_evidence_ids: List[str] = field(default_factory=list)
|
||||||
|
contradicting_evidence_ids: List[str] = field(default_factory=list)
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RetrievalHypothesis:
|
||||||
|
question_type: str = "fact"
|
||||||
|
direct_question_entities: List[HypothesisEntity] = field(default_factory=list)
|
||||||
|
hypothesized_entities: List[HypothesisEntity] = field(default_factory=list)
|
||||||
|
relation_intents: List[str] = field(default_factory=list)
|
||||||
|
concept_queries: List[str] = field(default_factory=list)
|
||||||
|
answer_slots: List[str] = field(default_factory=list)
|
||||||
|
atomic_claims: List[AtomicClaim] = field(default_factory=list)
|
||||||
|
negative_constraints: List[str] = field(default_factory=list)
|
||||||
|
parse_status: str = "ok"
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClaimContext:
|
||||||
|
claim_species: List[str] = field(default_factory=list)
|
||||||
|
paper_species: List[str] = field(default_factory=list)
|
||||||
|
species_source: str = "none"
|
||||||
|
subject: str = ""
|
||||||
|
function_or_process: str = ""
|
||||||
|
condition: str = ""
|
||||||
|
direction: str = ""
|
||||||
|
confidence: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EvidenceItem:
|
||||||
|
evidence_id: str
|
||||||
|
kb: str
|
||||||
|
source: str
|
||||||
|
relation: str
|
||||||
|
target: str
|
||||||
|
evidence: str
|
||||||
|
paper_id: str = ""
|
||||||
|
title: str = ""
|
||||||
|
pmid: str = ""
|
||||||
|
pmcid: str = ""
|
||||||
|
doi: str = ""
|
||||||
|
species: str = ""
|
||||||
|
section: str = ""
|
||||||
|
output_layer: str = ""
|
||||||
|
claim_scope: str = ""
|
||||||
|
relation_id: str = ""
|
||||||
|
retrieval_methods: List[str] = field(default_factory=list)
|
||||||
|
retrieval_queries: List[str] = field(default_factory=list)
|
||||||
|
vector_score: float = 0.0
|
||||||
|
rrf_score: float = 0.0
|
||||||
|
rerank_score: float = 0.0
|
||||||
|
final_score: float = 0.0
|
||||||
|
cluster_id: str = ""
|
||||||
|
cluster_member_count: int = 1
|
||||||
|
cluster_member_assertion_ids: List[str] = field(default_factory=list)
|
||||||
|
cluster_member_paper_ids: List[str] = field(default_factory=list)
|
||||||
|
cluster_member_pmids: List[str] = field(default_factory=list)
|
||||||
|
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
|
def to_dict(self, include_raw: bool = False) -> Dict[str, Any]:
|
||||||
|
value = asdict(self)
|
||||||
|
if not include_raw:
|
||||||
|
value.pop("raw", None)
|
||||||
|
return value
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any, Callable, Dict, Iterable, List
|
||||||
|
|
||||||
|
from .agentic_controller import AgenticOutputError
|
||||||
|
|
||||||
|
|
||||||
|
_STATUSES = {"SATISFIED", "PARTIAL", "EMPTY"}
|
||||||
|
_REFINEMENT_KEYS = {
|
||||||
|
"vector_queries",
|
||||||
|
"relation_name_hints",
|
||||||
|
"allow_incoming",
|
||||||
|
"allow_outgoing",
|
||||||
|
"max_hops",
|
||||||
|
"required_terminal_types",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _item_id(value: Any, key: str) -> str:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return _text(value.get(key))
|
||||||
|
return _text(getattr(value, key, ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_by_id(values: Iterable[Any], key: str) -> List[Any]:
|
||||||
|
output: List[Any] = []
|
||||||
|
seen = set()
|
||||||
|
for value in values:
|
||||||
|
marker = _item_id(value, key)
|
||||||
|
if not marker or marker in seen:
|
||||||
|
continue
|
||||||
|
seen.add(marker)
|
||||||
|
output.append(value)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
class SubquestionRetrievalOrchestrator:
|
||||||
|
"""Run independent retrieval groups with one bounded retry round."""
|
||||||
|
|
||||||
|
def __init__(self, max_workers: int = 3) -> None:
|
||||||
|
self.max_workers = max(1, min(int(max_workers), 3))
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
decomposition: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
retrieve: Callable[..., Dict[str, Any]],
|
||||||
|
review: Callable[..., Dict[str, Any]],
|
||||||
|
progress_callback: Callable[[Dict[str, Any]], None] | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
tasks = self._ordered_tasks(decomposition)
|
||||||
|
states = {
|
||||||
|
task["subquestion_id"]: {
|
||||||
|
"task": task,
|
||||||
|
"attempts": [],
|
||||||
|
"evidence": [],
|
||||||
|
"chains": [],
|
||||||
|
"status": "EMPTY",
|
||||||
|
"accepted_evidence_ids": [],
|
||||||
|
"accepted_chain_ids": [],
|
||||||
|
"refinement": {},
|
||||||
|
"stop_reason": "",
|
||||||
|
}
|
||||||
|
for task in tasks
|
||||||
|
}
|
||||||
|
review_rounds: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
self._retrieve_batch(
|
||||||
|
states,
|
||||||
|
[task["subquestion_id"] for task in tasks],
|
||||||
|
retrieve,
|
||||||
|
1,
|
||||||
|
progress_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
first_payload = [self._review_payload(states[task["subquestion_id"]]) for task in tasks]
|
||||||
|
self._emit(progress_callback, "agent_review_started", {"attempt": 1})
|
||||||
|
first_review = self._required_review(review, question, first_payload, 1)
|
||||||
|
review_rounds.append(deepcopy(first_review))
|
||||||
|
self._apply_review(states, first_review)
|
||||||
|
|
||||||
|
retry_ids: List[str] = []
|
||||||
|
for task in tasks:
|
||||||
|
state = states[task["subquestion_id"]]
|
||||||
|
if state["status"] not in {"PARTIAL", "EMPTY"}:
|
||||||
|
state["stop_reason"] = "satisfied_on_first_attempt"
|
||||||
|
continue
|
||||||
|
if not self._effective_refinement(state["refinement"]):
|
||||||
|
state["stop_reason"] = "no_effective_refinement"
|
||||||
|
continue
|
||||||
|
retry_ids.append(task["subquestion_id"])
|
||||||
|
|
||||||
|
if retry_ids:
|
||||||
|
for subquestion_id in retry_ids:
|
||||||
|
self._emit(
|
||||||
|
progress_callback,
|
||||||
|
"subquestion_retry_started",
|
||||||
|
{"subquestion_id": subquestion_id, "attempt": 2},
|
||||||
|
)
|
||||||
|
self._retrieve_batch(
|
||||||
|
states,
|
||||||
|
retry_ids,
|
||||||
|
retrieve,
|
||||||
|
2,
|
||||||
|
progress_callback,
|
||||||
|
)
|
||||||
|
second_payload = [self._review_payload(states[value]) for value in retry_ids]
|
||||||
|
self._emit(progress_callback, "agent_review_started", {"attempt": 2})
|
||||||
|
second_review = self._required_review(review, question, second_payload, 2)
|
||||||
|
review_rounds.append(deepcopy(second_review))
|
||||||
|
self._apply_review(states, second_review)
|
||||||
|
for subquestion_id in retry_ids:
|
||||||
|
states[subquestion_id]["stop_reason"] = "maximum_attempts_reached"
|
||||||
|
|
||||||
|
accepted_groups = []
|
||||||
|
audit_groups = []
|
||||||
|
for task in tasks:
|
||||||
|
state = states[task["subquestion_id"]]
|
||||||
|
evidence_by_id = {
|
||||||
|
_item_id(item, "evidence_id"): item
|
||||||
|
for item in state["evidence"]
|
||||||
|
if _item_id(item, "evidence_id")
|
||||||
|
}
|
||||||
|
chains_by_id = {
|
||||||
|
_item_id(item, "chain_id"): item
|
||||||
|
for item in state["chains"]
|
||||||
|
if _item_id(item, "chain_id")
|
||||||
|
}
|
||||||
|
accepted_evidence_ids = [
|
||||||
|
value
|
||||||
|
for value in state["accepted_evidence_ids"]
|
||||||
|
if value in evidence_by_id
|
||||||
|
]
|
||||||
|
accepted_chain_ids = [
|
||||||
|
value for value in state["accepted_chain_ids"] if value in chains_by_id
|
||||||
|
]
|
||||||
|
accepted_groups.append(
|
||||||
|
{
|
||||||
|
"subquestion_id": task["subquestion_id"],
|
||||||
|
"question": task.get("question") or "",
|
||||||
|
"role": task.get("role") or "supporting",
|
||||||
|
"answer_slot": task.get("answer_slot") or "",
|
||||||
|
"status": state["status"],
|
||||||
|
"accepted_evidence_ids": accepted_evidence_ids,
|
||||||
|
"accepted_chain_ids": accepted_chain_ids,
|
||||||
|
"evidence": [evidence_by_id[value] for value in accepted_evidence_ids],
|
||||||
|
"chains": [chains_by_id[value] for value in accepted_chain_ids],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
audit_groups.append(
|
||||||
|
{
|
||||||
|
"subquestion_id": task["subquestion_id"],
|
||||||
|
"question": task.get("question") or "",
|
||||||
|
"answer_slot": task.get("answer_slot") or "",
|
||||||
|
"status": state["status"],
|
||||||
|
"attempt_count": len(state["attempts"]),
|
||||||
|
"attempts": state["attempts"],
|
||||||
|
"stop_reason": state["stop_reason"] or "review_completed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"decomposition": decomposition,
|
||||||
|
"accepted_groups": accepted_groups,
|
||||||
|
"audit_groups": audit_groups,
|
||||||
|
"review_rounds": review_rounds,
|
||||||
|
}
|
||||||
|
self._emit(
|
||||||
|
progress_callback,
|
||||||
|
"evidence_review_completed",
|
||||||
|
{
|
||||||
|
"accepted_evidence_count": sum(
|
||||||
|
len(group["accepted_evidence_ids"]) for group in accepted_groups
|
||||||
|
),
|
||||||
|
"accepted_chain_count": sum(
|
||||||
|
len(group["accepted_chain_ids"]) for group in accepted_groups
|
||||||
|
),
|
||||||
|
"retried_subquestion_count": len(retry_ids),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _emit(
|
||||||
|
callback: Callable[[Dict[str, Any]], None] | None,
|
||||||
|
stage: str,
|
||||||
|
details: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
if callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
callback({"stage": stage, "details": details})
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _emit_retrieval_progress(
|
||||||
|
cls,
|
||||||
|
callback: Callable[[Dict[str, Any]], None] | None,
|
||||||
|
state: Dict[str, Any],
|
||||||
|
attempt: int,
|
||||||
|
) -> None:
|
||||||
|
latest = state["attempts"][-1] if state.get("attempts") else {}
|
||||||
|
cls._emit(
|
||||||
|
callback,
|
||||||
|
"subquestion_retrieved",
|
||||||
|
{
|
||||||
|
"subquestion_id": state["task"].get("subquestion_id") or "",
|
||||||
|
"attempt": attempt,
|
||||||
|
"candidate_count": len(latest.get("evidence_top30") or []),
|
||||||
|
"connection_count": len(latest.get("chains") or []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ordered_tasks(decomposition: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
raw_tasks = decomposition.get("subquestions") or []
|
||||||
|
by_id = {
|
||||||
|
_text(task.get("subquestion_id")): task
|
||||||
|
for task in raw_tasks
|
||||||
|
if isinstance(task, dict) and _text(task.get("subquestion_id"))
|
||||||
|
}
|
||||||
|
order = [value for value in decomposition.get("synthesis_order") or [] if value in by_id]
|
||||||
|
if not order:
|
||||||
|
order = list(by_id)
|
||||||
|
return [by_id[value] for value in order]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _retrieve_into_state(
|
||||||
|
state: Dict[str, Any],
|
||||||
|
retrieve: Callable[..., Dict[str, Any]],
|
||||||
|
attempt: int,
|
||||||
|
refinement: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
result = SubquestionRetrievalOrchestrator._retrieve_once(
|
||||||
|
state["task"],
|
||||||
|
retrieve,
|
||||||
|
attempt,
|
||||||
|
refinement,
|
||||||
|
)
|
||||||
|
SubquestionRetrievalOrchestrator._apply_attempt_result(
|
||||||
|
state,
|
||||||
|
attempt,
|
||||||
|
refinement,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _retrieve_batch(
|
||||||
|
self,
|
||||||
|
states: Dict[str, Dict[str, Any]],
|
||||||
|
subquestion_ids: List[str],
|
||||||
|
retrieve: Callable[..., Dict[str, Any]],
|
||||||
|
attempt: int,
|
||||||
|
progress_callback: Callable[[Dict[str, Any]], None] | None,
|
||||||
|
) -> None:
|
||||||
|
if not subquestion_ids:
|
||||||
|
return
|
||||||
|
refinements = {
|
||||||
|
subquestion_id: (
|
||||||
|
deepcopy(states[subquestion_id]["refinement"])
|
||||||
|
if attempt > 1
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
for subquestion_id in subquestion_ids
|
||||||
|
}
|
||||||
|
if self.max_workers == 1 or len(subquestion_ids) == 1:
|
||||||
|
results = {
|
||||||
|
subquestion_id: self._retrieve_once(
|
||||||
|
states[subquestion_id]["task"],
|
||||||
|
retrieve,
|
||||||
|
attempt,
|
||||||
|
refinements[subquestion_id],
|
||||||
|
)
|
||||||
|
for subquestion_id in subquestion_ids
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
with ThreadPoolExecutor(
|
||||||
|
max_workers=min(self.max_workers, len(subquestion_ids)),
|
||||||
|
thread_name_prefix="qa-subquestion",
|
||||||
|
) as executor:
|
||||||
|
futures = {
|
||||||
|
subquestion_id: executor.submit(
|
||||||
|
self._retrieve_once,
|
||||||
|
states[subquestion_id]["task"],
|
||||||
|
retrieve,
|
||||||
|
attempt,
|
||||||
|
refinements[subquestion_id],
|
||||||
|
)
|
||||||
|
for subquestion_id in subquestion_ids
|
||||||
|
}
|
||||||
|
results = {
|
||||||
|
subquestion_id: futures[subquestion_id].result()
|
||||||
|
for subquestion_id in subquestion_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
for subquestion_id in subquestion_ids:
|
||||||
|
state = states[subquestion_id]
|
||||||
|
self._apply_attempt_result(
|
||||||
|
state,
|
||||||
|
attempt,
|
||||||
|
refinements[subquestion_id],
|
||||||
|
results[subquestion_id],
|
||||||
|
)
|
||||||
|
self._emit_retrieval_progress(progress_callback, state, attempt)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _retrieve_once(
|
||||||
|
task: Dict[str, Any],
|
||||||
|
retrieve: Callable[..., Dict[str, Any]],
|
||||||
|
attempt: int,
|
||||||
|
refinement: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
result = retrieve(
|
||||||
|
task,
|
||||||
|
attempt=attempt,
|
||||||
|
refinement=deepcopy(refinement),
|
||||||
|
)
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise TypeError("subquestion retrieval result must be a dict")
|
||||||
|
error = ""
|
||||||
|
except Exception as exc:
|
||||||
|
result = {}
|
||||||
|
error = f"{type(exc).__name__}: {exc}"[:500]
|
||||||
|
return {
|
||||||
|
"evidence_top30": list(result.get("evidence_top30") or [])[:30],
|
||||||
|
"chains": list(result.get("chains") or []),
|
||||||
|
"retrieval_audit": deepcopy(result.get("retrieval_audit") or {}),
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_attempt_result(
|
||||||
|
state: Dict[str, Any],
|
||||||
|
attempt: int,
|
||||||
|
refinement: Dict[str, Any],
|
||||||
|
result: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
evidence = list(result.get("evidence_top30") or [])[:30]
|
||||||
|
chains = list(result.get("chains") or [])
|
||||||
|
state["evidence"] = _dedupe_by_id(
|
||||||
|
list(state["evidence"]) + evidence, "evidence_id"
|
||||||
|
)
|
||||||
|
state["chains"] = _dedupe_by_id(list(state["chains"]) + chains, "chain_id")
|
||||||
|
state["attempts"].append(
|
||||||
|
{
|
||||||
|
"attempt": attempt,
|
||||||
|
"refinement": deepcopy(refinement),
|
||||||
|
"evidence_top30": evidence,
|
||||||
|
"chains": chains,
|
||||||
|
"retrieval_audit": deepcopy(result.get("retrieval_audit") or {}),
|
||||||
|
"error": _text(result.get("error"))[:500],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _review_payload(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"subquestion_id": state["task"]["subquestion_id"],
|
||||||
|
"question": state["task"].get("question") or "",
|
||||||
|
"role": state["task"].get("role") or "supporting",
|
||||||
|
"answer_slot": state["task"].get("answer_slot") or "",
|
||||||
|
"anchors": state["task"].get("anchors") or [],
|
||||||
|
"target_entity_types": state["task"].get("target_entity_types") or [],
|
||||||
|
"acceptance_requirements": state["task"].get("acceptance_requirements") or {},
|
||||||
|
"evidence": list(state["evidence"]),
|
||||||
|
"chains": list(state["chains"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _required_review(
|
||||||
|
review: Callable[..., Dict[str, Any]],
|
||||||
|
question: str,
|
||||||
|
groups: List[Dict[str, Any]],
|
||||||
|
attempt: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
result = review(question, groups, attempt=attempt)
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise AgenticOutputError(
|
||||||
|
"evidence_review",
|
||||||
|
"subquestion review must return a JSON object",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_review(states: Dict[str, Dict[str, Any]], review: Dict[str, Any]) -> None:
|
||||||
|
for raw in review.get("groups") or []:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
subquestion_id = _text(raw.get("subquestion_id"))
|
||||||
|
if subquestion_id not in states:
|
||||||
|
continue
|
||||||
|
state = states[subquestion_id]
|
||||||
|
valid_evidence_ids = {
|
||||||
|
_item_id(item, "evidence_id") for item in state["evidence"]
|
||||||
|
}
|
||||||
|
valid_chain_ids = {_item_id(item, "chain_id") for item in state["chains"]}
|
||||||
|
status = _text(raw.get("status")).upper()
|
||||||
|
state["status"] = status if status in _STATUSES else "EMPTY"
|
||||||
|
state["accepted_evidence_ids"] = [
|
||||||
|
value
|
||||||
|
for value in dict.fromkeys(raw.get("accepted_evidence_ids") or [])
|
||||||
|
if value in valid_evidence_ids
|
||||||
|
]
|
||||||
|
state["accepted_chain_ids"] = [
|
||||||
|
value
|
||||||
|
for value in dict.fromkeys(raw.get("accepted_chain_ids") or [])
|
||||||
|
if value in valid_chain_ids
|
||||||
|
]
|
||||||
|
state["refinement"] = SubquestionRetrievalOrchestrator._sanitize_refinement(
|
||||||
|
raw.get("refinement")
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_refinement(value: Any) -> Dict[str, Any]:
|
||||||
|
raw = value if isinstance(value, dict) else {}
|
||||||
|
return {key: deepcopy(raw[key]) for key in _REFINEMENT_KEYS if key in raw}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _effective_refinement(value: Dict[str, Any]) -> bool:
|
||||||
|
for key, item in value.items():
|
||||||
|
if key in {"allow_incoming", "allow_outgoing"}:
|
||||||
|
return True
|
||||||
|
if item not in (None, "", [], {}):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, Iterable, List
|
||||||
|
|
||||||
|
from .natural_question_compiler import NaturalQuestionPlan
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: Any) -> str:
|
||||||
|
text = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", str(value or "").casefold())
|
||||||
|
return " ".join(text.split())
|
||||||
|
|
||||||
|
|
||||||
|
def _stem_tokens(value: Any) -> set[str]:
|
||||||
|
tokens = set(_norm(value).split())
|
||||||
|
stems = set()
|
||||||
|
for token in tokens:
|
||||||
|
if len(token) > 5 and token.endswith("ing"):
|
||||||
|
token = token[:-3]
|
||||||
|
elif len(token) > 4 and token.endswith("ed"):
|
||||||
|
token = token[:-2]
|
||||||
|
elif len(token) > 3 and token.endswith("s"):
|
||||||
|
token = token[:-1]
|
||||||
|
stems.add(token)
|
||||||
|
return stems
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AnswerRanking:
|
||||||
|
answer: str
|
||||||
|
candidates: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalAnswerRanker:
|
||||||
|
"""Aggregate terminal endpoints without access to benchmark gold fields."""
|
||||||
|
|
||||||
|
_TYPE_CATEGORIES = {
|
||||||
|
"classification": {"classification", "family_membership", "orthology_homology"},
|
||||||
|
"condition": {"treatment_or_stress", "expression_condition", "stress_response", "hormone_response", "pathogen_response"},
|
||||||
|
"phenotype": {"phenotype_effect", "yield_trait", "quality_trait", "developmental_regulation"},
|
||||||
|
"gene_or_protein": {"protein_interaction", "regulatory_interaction", "orthology_homology"},
|
||||||
|
"location": {"subcellular_localization", "expression_tissue", "chromosomal_location"},
|
||||||
|
"method": {"method_tool"},
|
||||||
|
"biological_process": {"pathway_participation", "developmental_regulation", "metabolic_change"},
|
||||||
|
}
|
||||||
|
_TYPE_WORDS = {
|
||||||
|
"classification": {"species", "taxon", "family", "classification"},
|
||||||
|
"condition": {"condition", "stress", "treatment", "hormone", "pathogen"},
|
||||||
|
"phenotype": {"phenotype", "trait", "outcome"},
|
||||||
|
"gene_or_protein": {"gene", "protein"},
|
||||||
|
"location": {"location", "compartment", "tissue", "organ"},
|
||||||
|
"method": {"method", "tool", "approach"},
|
||||||
|
"biological_process": {"process", "pathway"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def rank(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
plan: NaturalQuestionPlan,
|
||||||
|
chains: Iterable[Dict[str, Any]],
|
||||||
|
) -> AnswerRanking:
|
||||||
|
final_clause = re.split(r"[.!?]+", question)[-2 if re.search(r"[.!?]\s*$", question) else -1]
|
||||||
|
clause_tokens = _stem_tokens(final_clause)
|
||||||
|
grouped: Dict[str, Dict[str, Any]] = {}
|
||||||
|
all_one_hop = True
|
||||||
|
for index, chain in enumerate(chains):
|
||||||
|
hops = list(chain.get("hops") or [])
|
||||||
|
all_one_hop = all_one_hop and len(hops) == 1
|
||||||
|
terminal = str(chain.get("end") or (hops[-1].get("target") if hops else "")).strip()
|
||||||
|
key = _norm(terminal)
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
item = grouped.setdefault(key, {"answer": terminal, "chains": [], "first_rank": index})
|
||||||
|
item["chains"].append(chain)
|
||||||
|
|
||||||
|
candidates: List[Dict[str, Any]] = []
|
||||||
|
for key, item in grouped.items():
|
||||||
|
endpoint_chains = item["chains"]
|
||||||
|
unique_sequences = {
|
||||||
|
tuple(str(hop.get("assertion_id") or "") for hop in chain.get("hops") or [])
|
||||||
|
for chain in endpoint_chains
|
||||||
|
}
|
||||||
|
unique_sequences.discard(())
|
||||||
|
support = max(1, len(unique_sequences))
|
||||||
|
best_path = max(float(chain.get("path_score") or 0.0) for chain in endpoint_chains)
|
||||||
|
completeness = max(
|
||||||
|
min(len(chain.get("hops") or []) / max(1, int(plan.estimated_hops or 1)), 1.0)
|
||||||
|
for chain in endpoint_chains
|
||||||
|
)
|
||||||
|
compatibility = max(self._compatibility(plan.answer_type, chain) for chain in endpoint_chains)
|
||||||
|
answer_tokens = _stem_tokens(item["answer"])
|
||||||
|
lexical_similarity = len(answer_tokens & clause_tokens) / max(1, len(answer_tokens))
|
||||||
|
support_score = min(support / 2.0, 1.0)
|
||||||
|
retrieval_rank_prior = 1.0 / (1.0 + item["first_rank"])
|
||||||
|
score = (0.25 * best_path + 0.05 * support_score + 0.20 * completeness
|
||||||
|
+ 0.20 * compatibility + 0.15 * lexical_similarity + 0.15 * retrieval_rank_prior)
|
||||||
|
candidates.append({
|
||||||
|
"answer": item["answer"],
|
||||||
|
"normalized_answer": key,
|
||||||
|
"score": round(score, 6),
|
||||||
|
"best_path_score": best_path,
|
||||||
|
"independent_support": support,
|
||||||
|
"path_completeness": completeness,
|
||||||
|
"answer_type_compatibility": compatibility,
|
||||||
|
"final_clause_similarity": lexical_similarity,
|
||||||
|
"retrieval_rank_prior": retrieval_rank_prior,
|
||||||
|
"first_chain_rank": item["first_rank"],
|
||||||
|
})
|
||||||
|
candidates.sort(key=lambda row: (-row["score"], row["first_chain_rank"], row["normalized_answer"]))
|
||||||
|
if int(plan.estimated_hops or 1) == 1 and candidates and all_one_hop:
|
||||||
|
candidates.sort(key=lambda row: (row["first_chain_rank"], -row["score"], row["normalized_answer"]))
|
||||||
|
return AnswerRanking(answer=candidates[0]["answer"] if candidates else "", candidates=candidates)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _compatibility(cls, answer_type: str, chain: Dict[str, Any]) -> float:
|
||||||
|
hops = list(chain.get("hops") or [])
|
||||||
|
if not hops or answer_type == "unknown":
|
||||||
|
return 0.5
|
||||||
|
last = hops[-1]
|
||||||
|
category = _norm(last.get("relation_category")).replace(" ", "_")
|
||||||
|
target_type = set(_norm(last.get("target_type")).split())
|
||||||
|
if category in cls._TYPE_CATEGORIES.get(answer_type, set()):
|
||||||
|
return 1.0
|
||||||
|
if target_type & cls._TYPE_WORDS.get(answer_type, set()):
|
||||||
|
return 0.85
|
||||||
|
return 0.0
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from neo4j import GraphDatabase
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.-]{2,}|[\u4e00-\u9fff]{2,}")
|
||||||
|
STOPWORDS = {
|
||||||
|
"what", "which", "where", "when", "with", "from", "that", "this",
|
||||||
|
"genes", "gene", "protein", "proteins", "reported", "associated",
|
||||||
|
"plant", "plants", "species", "family", "families",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class V16IndependentRetriever:
|
||||||
|
"""Independent V16/V15 SQLite + Qdrant + Neo4j retriever."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
root = Path(os.environ.get("GENE_FAMILY_ROOT", "/data/gene-family-mas-v2"))
|
||||||
|
kb_dir = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"V15_SRT_KB_DIR",
|
||||||
|
root / "data" / "knowledge_base" / "v15_srt_kb_20260525",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.sqlite_path = Path(
|
||||||
|
os.environ.get("QA_V1_V16_SQLITE", kb_dir / "kb.sqlite")
|
||||||
|
)
|
||||||
|
self.qdrant_url = os.environ.get(
|
||||||
|
"V15_QDRANT_URL",
|
||||||
|
os.environ.get("QDRANT_URL", "http://127.0.0.1:6333"),
|
||||||
|
)
|
||||||
|
self.collection = os.environ.get(
|
||||||
|
"V15_QDRANT_COLLECTION", "v15_q1q2_1770_srt_bge_20260610"
|
||||||
|
)
|
||||||
|
self.neo4j_uri = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:7687")
|
||||||
|
self.neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
|
||||||
|
self.neo4j_password = os.environ.get("NEO4J_PASSWORD", "password")
|
||||||
|
self.graph_schema = os.environ.get("QA_V1_GRAPH_SCHEMA", "legacy").strip().lower()
|
||||||
|
self.embed_url = os.environ.get(
|
||||||
|
"QA_V1_EMBED_URL",
|
||||||
|
os.environ.get(
|
||||||
|
"V15_OLLAMA_EMBED_URL",
|
||||||
|
"http://127.0.0.1:11434/api/embed",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.embed_model = os.environ.get(
|
||||||
|
"QA_V1_EMBED_MODEL",
|
||||||
|
os.environ.get("V15_BGE_MODEL", "bge-m3:567m"),
|
||||||
|
)
|
||||||
|
self.qdrant = QdrantClient(url=self.qdrant_url, timeout=30)
|
||||||
|
self.driver = GraphDatabase.driver(
|
||||||
|
self.neo4j_uri,
|
||||||
|
auth=(self.neo4j_user, self.neo4j_password),
|
||||||
|
)
|
||||||
|
self.sqlite: sqlite3.Connection | None = None
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
top_k: int = 30,
|
||||||
|
max_hops: int = 2,
|
||||||
|
include_candidate: bool = False,
|
||||||
|
*,
|
||||||
|
use_sqlite: bool = True,
|
||||||
|
use_qdrant: bool = True,
|
||||||
|
use_neo4j: bool = True,
|
||||||
|
use_graph_expansion: bool = True,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
sqlite_rows = self.sqlite_search(question, top_k, include_candidate) if use_sqlite else []
|
||||||
|
vector_rows = self.vector_search(question, top_k, include_candidate) if use_qdrant else []
|
||||||
|
graph_rows = self.graph_search(question, top_k, include_candidate) if use_neo4j else []
|
||||||
|
expansion = (
|
||||||
|
self.expand_graph(graph_rows[:8], top_k, include_candidate)
|
||||||
|
if use_neo4j and use_graph_expansion and max_hops >= 2
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return sqlite_rows + vector_rows + graph_rows + expansion
|
||||||
|
|
||||||
|
def embed(self, text: str) -> List[float]:
|
||||||
|
response = requests.post(
|
||||||
|
self.embed_url,
|
||||||
|
json={"model": self.embed_model, "input": text[:2400]},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
embeddings = response.json().get("embeddings") or []
|
||||||
|
if not embeddings:
|
||||||
|
raise RuntimeError("V16 embedding endpoint returned no vector")
|
||||||
|
return embeddings[0]
|
||||||
|
|
||||||
|
def vector_search(
|
||||||
|
self, question: str, limit: int, include_candidate: bool
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
response = self.qdrant.query_points(
|
||||||
|
collection_name=self.collection,
|
||||||
|
query=self.embed(question),
|
||||||
|
limit=limit * 2,
|
||||||
|
with_payload=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
output = []
|
||||||
|
for point in response.points:
|
||||||
|
row = dict(point.payload or {})
|
||||||
|
if not include_candidate and (
|
||||||
|
row.get("candidate_status") == "candidate"
|
||||||
|
or row.get("output_layer") == "candidate_review"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
row["_vector_score"] = float(point.score or 0.0)
|
||||||
|
row["_retrieval_method"] = "v16_qdrant_bge"
|
||||||
|
output.append(row)
|
||||||
|
if len(output) >= limit:
|
||||||
|
break
|
||||||
|
return output
|
||||||
|
|
||||||
|
def sqlite_search(
|
||||||
|
self, question: str, limit: int, include_candidate: bool
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if not self.sqlite_path.exists():
|
||||||
|
return []
|
||||||
|
if self.sqlite is None:
|
||||||
|
self.sqlite = sqlite3.connect(str(self.sqlite_path))
|
||||||
|
self.sqlite.row_factory = sqlite3.Row
|
||||||
|
tokens = self.query_terms(question)[:5]
|
||||||
|
if not tokens:
|
||||||
|
return []
|
||||||
|
query = " OR ".join(f'"{token}"' for token in tokens)
|
||||||
|
candidate_clause = (
|
||||||
|
"1=1"
|
||||||
|
if include_candidate
|
||||||
|
else "COALESCE(r.candidate_status, '') != 'candidate'"
|
||||||
|
)
|
||||||
|
statement = f"""
|
||||||
|
SELECT r.*
|
||||||
|
FROM relations r
|
||||||
|
JOIN relations_fts fts ON r.rowid = fts.rowid
|
||||||
|
WHERE relations_fts MATCH ? AND {candidate_clause}
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = [
|
||||||
|
dict(row)
|
||||||
|
for row in self.sqlite.execute(statement, [query, limit])
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
for row in rows:
|
||||||
|
row["_retrieval_method"] = "v16_sqlite_fts"
|
||||||
|
row["_vector_score"] = 1.0
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def graph_search(
|
||||||
|
self, question: str, limit: int, include_candidate: bool
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if self.graph_schema == "canonical":
|
||||||
|
return self.canonical_graph_search(question, limit, include_candidate)
|
||||||
|
return self.legacy_graph_search(question, limit, include_candidate)
|
||||||
|
|
||||||
|
def legacy_graph_search(
|
||||||
|
self, question: str, limit: int, include_candidate: bool
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
terms = self.query_terms(question)
|
||||||
|
if not terms:
|
||||||
|
return []
|
||||||
|
layers = self.layers(include_candidate)
|
||||||
|
statement = """
|
||||||
|
MATCH (e:Entity)
|
||||||
|
WHERE toLower(e.name) IN $terms
|
||||||
|
OR toLower(coalesce(e.name_norm, e.name)) IN $terms
|
||||||
|
WITH e LIMIT 24
|
||||||
|
MATCH (e)-[r:RELATION]-(n:Entity)
|
||||||
|
WHERE r.output_layer IN $layers
|
||||||
|
RETURN startNode(r).name AS source,
|
||||||
|
startNode(r).entity_type AS source_type,
|
||||||
|
r.relation AS relation,
|
||||||
|
endNode(r).name AS target,
|
||||||
|
endNode(r).entity_type AS target_type,
|
||||||
|
r.evidence AS evidence,
|
||||||
|
r.section AS section,
|
||||||
|
r.pubmed_id AS pubmed_id,
|
||||||
|
r.paper_id AS paper_id,
|
||||||
|
r.canonical_paper_id AS canonical_paper_id,
|
||||||
|
r.confidence AS confidence,
|
||||||
|
r.claim_scope AS claim_scope,
|
||||||
|
r.output_layer AS output_layer,
|
||||||
|
r.candidate_status AS candidate_status,
|
||||||
|
r.relation_id AS relation_id
|
||||||
|
ORDER BY CASE WHEN coalesce(r.evidence, '') = '' THEN 1 ELSE 0 END,
|
||||||
|
r.confidence DESC
|
||||||
|
LIMIT $limit
|
||||||
|
"""
|
||||||
|
with self.driver.session() as session:
|
||||||
|
rows = [
|
||||||
|
dict(record)
|
||||||
|
for record in session.run(
|
||||||
|
statement,
|
||||||
|
terms=terms,
|
||||||
|
layers=layers,
|
||||||
|
limit=min(limit * 2, 100),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
row["_retrieval_method"] = "v16_neo4j_exact"
|
||||||
|
row["_vector_score"] = 1.0
|
||||||
|
return rows[:limit]
|
||||||
|
|
||||||
|
def expand_graph(
|
||||||
|
self,
|
||||||
|
seeds: List[Dict[str, Any]],
|
||||||
|
limit: int,
|
||||||
|
include_candidate: bool,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if self.graph_schema == "canonical":
|
||||||
|
return self.canonical_expand_graph(seeds, limit, include_candidate)
|
||||||
|
return self.legacy_expand_graph(seeds, limit, include_candidate)
|
||||||
|
|
||||||
|
def legacy_expand_graph(
|
||||||
|
self,
|
||||||
|
seeds: List[Dict[str, Any]],
|
||||||
|
limit: int,
|
||||||
|
include_candidate: bool,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
names = []
|
||||||
|
for row in seeds:
|
||||||
|
for value in (row.get("source"), row.get("target")):
|
||||||
|
name = " ".join(str(value or "").lower().split())
|
||||||
|
if name and name not in names:
|
||||||
|
names.append(name)
|
||||||
|
if not names:
|
||||||
|
return []
|
||||||
|
statement = """
|
||||||
|
MATCH (e:Entity)
|
||||||
|
WHERE toLower(e.name) IN $names
|
||||||
|
OR toLower(coalesce(e.name_norm, e.name)) IN $names
|
||||||
|
WITH e LIMIT 10
|
||||||
|
MATCH (e)-[r:RELATION]-(n:Entity)
|
||||||
|
WHERE r.output_layer IN $layers
|
||||||
|
RETURN startNode(r).name AS source,
|
||||||
|
startNode(r).entity_type AS source_type,
|
||||||
|
r.relation AS relation,
|
||||||
|
endNode(r).name AS target,
|
||||||
|
endNode(r).entity_type AS target_type,
|
||||||
|
r.evidence AS evidence,
|
||||||
|
r.section AS section,
|
||||||
|
r.pubmed_id AS pubmed_id,
|
||||||
|
r.paper_id AS paper_id,
|
||||||
|
r.canonical_paper_id AS canonical_paper_id,
|
||||||
|
r.confidence AS confidence,
|
||||||
|
r.claim_scope AS claim_scope,
|
||||||
|
r.output_layer AS output_layer,
|
||||||
|
r.candidate_status AS candidate_status,
|
||||||
|
r.relation_id AS relation_id
|
||||||
|
LIMIT $limit
|
||||||
|
"""
|
||||||
|
with self.driver.session() as session:
|
||||||
|
rows = [
|
||||||
|
dict(record)
|
||||||
|
for record in session.run(
|
||||||
|
statement,
|
||||||
|
names=names[:10],
|
||||||
|
layers=self.layers(include_candidate),
|
||||||
|
limit=min(limit, 80),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
row["_retrieval_method"] = "v16_neo4j_expand"
|
||||||
|
row["_vector_score"] = 0.8
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_graph_search(
|
||||||
|
self, question: str, limit: int, include_candidate: bool
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
terms = self.canonical_seed_terms(question)
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
if terms:
|
||||||
|
statement = """
|
||||||
|
UNWIND $terms AS term
|
||||||
|
MATCH (e:SRT2CanonicalEntity)
|
||||||
|
WHERE e.name_norm = term OR toLower(e.name) = term
|
||||||
|
WITH e, max(size(term)) AS term_len
|
||||||
|
ORDER BY term_len DESC
|
||||||
|
LIMIT 24
|
||||||
|
MATCH (e)-[r:SRT2_CANONICAL_ASSERTS]-(n:SRT2CanonicalEntity)
|
||||||
|
MATCH (a:SRT2CanonicalAssertion {assertion_id: r.assertion_id})
|
||||||
|
RETURN a.source AS source,
|
||||||
|
a.source_type AS source_type,
|
||||||
|
a.relation_norm AS relation,
|
||||||
|
a.target AS target,
|
||||||
|
a.target_type AS target_type,
|
||||||
|
a.best_evidence AS evidence,
|
||||||
|
'' AS section,
|
||||||
|
'' AS pubmed_id,
|
||||||
|
a.best_paper_id AS paper_id,
|
||||||
|
a.best_paper_id AS canonical_paper_id,
|
||||||
|
a.best_score AS confidence,
|
||||||
|
'' AS claim_scope,
|
||||||
|
'main_kg' AS output_layer,
|
||||||
|
'' AS candidate_status,
|
||||||
|
a.assertion_id AS relation_id,
|
||||||
|
a.assertion_id AS assertion_id,
|
||||||
|
a.source_canonical_id AS source_canonical_id,
|
||||||
|
a.target_canonical_id AS target_canonical_id,
|
||||||
|
a.relation_category AS relation_category,
|
||||||
|
a.support_count AS support_count,
|
||||||
|
a.paper_count AS paper_count,
|
||||||
|
e.canonical_id AS matched_canonical_id,
|
||||||
|
e.name AS matched_canonical_name
|
||||||
|
ORDER BY coalesce(a.paper_count, 0) DESC,
|
||||||
|
coalesce(a.support_count, 0) DESC,
|
||||||
|
coalesce(a.best_score, 0.0) DESC
|
||||||
|
LIMIT $limit
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self.driver.session() as session:
|
||||||
|
rows = [
|
||||||
|
dict(record)
|
||||||
|
for record in session.run(
|
||||||
|
statement,
|
||||||
|
terms=terms,
|
||||||
|
limit=min(limit * 2, 100),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
if len(rows) < max(3, limit // 4):
|
||||||
|
rows.extend(
|
||||||
|
self.canonical_fulltext_assertion_search(
|
||||||
|
question, max(limit - len(rows), 0), include_candidate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output: List[Dict[str, Any]] = []
|
||||||
|
seen = set()
|
||||||
|
for row in rows:
|
||||||
|
rid = str(row.get("relation_id") or row.get("assertion_id") or "")
|
||||||
|
key = rid or (row.get("source"), row.get("relation"), row.get("target"))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
row["_retrieval_method"] = row.get("_retrieval_method") or "v16_neo4j_canonical_exact"
|
||||||
|
row["_vector_score"] = float(row.get("_vector_score") or row.get("confidence") or 1.0)
|
||||||
|
row["graph_schema"] = "canonical"
|
||||||
|
output.append(row)
|
||||||
|
if len(output) >= limit:
|
||||||
|
break
|
||||||
|
return output
|
||||||
|
|
||||||
|
def canonical_fulltext_assertion_search(
|
||||||
|
self, question: str, limit: int, include_candidate: bool
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if limit <= 0:
|
||||||
|
return []
|
||||||
|
query = self.fulltext_query(question)
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
statement = """
|
||||||
|
CALL db.index.fulltext.queryNodes('srt2_assertion_fulltext', $query, {limit: $limit})
|
||||||
|
YIELD node, score
|
||||||
|
RETURN node.source AS source,
|
||||||
|
node.source_type AS source_type,
|
||||||
|
node.relation_norm AS relation,
|
||||||
|
node.target AS target,
|
||||||
|
node.target_type AS target_type,
|
||||||
|
node.best_evidence AS evidence,
|
||||||
|
'' AS section,
|
||||||
|
'' AS pubmed_id,
|
||||||
|
node.best_paper_id AS paper_id,
|
||||||
|
node.best_paper_id AS canonical_paper_id,
|
||||||
|
node.best_score AS confidence,
|
||||||
|
'' AS claim_scope,
|
||||||
|
CASE WHEN 'candidate_review' IN coalesce(node.layers, []) THEN 'candidate_review' ELSE 'main_kg' END AS output_layer,
|
||||||
|
'' AS candidate_status,
|
||||||
|
node.assertion_id AS relation_id,
|
||||||
|
node.assertion_id AS assertion_id,
|
||||||
|
'' AS source_canonical_id,
|
||||||
|
'' AS target_canonical_id,
|
||||||
|
node.relation_category AS relation_category,
|
||||||
|
node.support_count AS support_count,
|
||||||
|
node.paper_count AS paper_count,
|
||||||
|
score AS fulltext_score
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self.driver.session() as session:
|
||||||
|
rows = [
|
||||||
|
dict(record)
|
||||||
|
for record in session.run(statement, query=query, limit=min(limit, 80))
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
output = []
|
||||||
|
for row in rows:
|
||||||
|
if not include_candidate and row.get("output_layer") == "candidate_review":
|
||||||
|
continue
|
||||||
|
row["_retrieval_method"] = "v16_neo4j_canonical_fulltext"
|
||||||
|
row["_vector_score"] = float(row.get("fulltext_score") or row.get("confidence") or 0.8)
|
||||||
|
row["graph_schema"] = "canonical_raw_assertion_fulltext"
|
||||||
|
output.append(row)
|
||||||
|
if len(output) >= limit:
|
||||||
|
break
|
||||||
|
return output
|
||||||
|
|
||||||
|
def canonical_expand_graph(
|
||||||
|
self,
|
||||||
|
seeds: List[Dict[str, Any]],
|
||||||
|
limit: int,
|
||||||
|
include_candidate: bool,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
ids = []
|
||||||
|
names = []
|
||||||
|
for row in seeds:
|
||||||
|
for value in (row.get("source_canonical_id"), row.get("target_canonical_id")):
|
||||||
|
value = str(value or "").strip()
|
||||||
|
if value and value not in ids:
|
||||||
|
ids.append(value)
|
||||||
|
for value in (row.get("source"), row.get("target"), row.get("matched_canonical_name")):
|
||||||
|
name = self.normalize_name(value)
|
||||||
|
if name and name not in names:
|
||||||
|
names.append(name)
|
||||||
|
if not ids and not names:
|
||||||
|
return []
|
||||||
|
statement = """
|
||||||
|
MATCH (e:SRT2CanonicalEntity)
|
||||||
|
WHERE e.canonical_id IN $ids OR e.name_norm IN $names OR toLower(e.name) IN $names
|
||||||
|
WITH e LIMIT 10
|
||||||
|
MATCH (e)-[r:SRT2_CANONICAL_ASSERTS]-(n:SRT2CanonicalEntity)
|
||||||
|
MATCH (a:SRT2CanonicalAssertion {assertion_id: r.assertion_id})
|
||||||
|
RETURN a.source AS source,
|
||||||
|
a.source_type AS source_type,
|
||||||
|
a.relation_norm AS relation,
|
||||||
|
a.target AS target,
|
||||||
|
a.target_type AS target_type,
|
||||||
|
a.best_evidence AS evidence,
|
||||||
|
'' AS section,
|
||||||
|
'' AS pubmed_id,
|
||||||
|
a.best_paper_id AS paper_id,
|
||||||
|
a.best_paper_id AS canonical_paper_id,
|
||||||
|
a.best_score AS confidence,
|
||||||
|
'' AS claim_scope,
|
||||||
|
'main_kg' AS output_layer,
|
||||||
|
'' AS candidate_status,
|
||||||
|
a.assertion_id AS relation_id,
|
||||||
|
a.assertion_id AS assertion_id,
|
||||||
|
a.source_canonical_id AS source_canonical_id,
|
||||||
|
a.target_canonical_id AS target_canonical_id,
|
||||||
|
a.relation_category AS relation_category,
|
||||||
|
a.support_count AS support_count,
|
||||||
|
a.paper_count AS paper_count
|
||||||
|
ORDER BY coalesce(a.paper_count, 0) DESC,
|
||||||
|
coalesce(a.support_count, 0) DESC,
|
||||||
|
coalesce(a.best_score, 0.0) DESC
|
||||||
|
LIMIT $limit
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self.driver.session() as session:
|
||||||
|
rows = [
|
||||||
|
dict(record)
|
||||||
|
for record in session.run(
|
||||||
|
statement,
|
||||||
|
ids=ids[:20],
|
||||||
|
names=names[:20],
|
||||||
|
limit=min(limit, 80),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
for row in rows:
|
||||||
|
row["_retrieval_method"] = "v16_neo4j_canonical_expand"
|
||||||
|
row["_vector_score"] = float(row.get("confidence") or 0.8)
|
||||||
|
row["graph_schema"] = "canonical"
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def canonical_seed_terms(cls, text: str) -> List[str]:
|
||||||
|
generic = {
|
||||||
|
"gene", "genes", "family", "families", "protein", "proteins",
|
||||||
|
"expression", "expressed", "reported", "associated", "tissue",
|
||||||
|
"tissues", "condition", "conditions", "stress", "stresses",
|
||||||
|
}
|
||||||
|
raw_words = [w.lower() for w in TOKEN_RE.findall(str(text or "")) if len(w) >= 2]
|
||||||
|
meaningful = [w for w in raw_words if w not in generic and w not in STOPWORDS]
|
||||||
|
output: List[str] = []
|
||||||
|
normalized = cls.normalize_name(text)
|
||||||
|
if 2 <= len(normalized) <= 160 and any(w in normalized.split() for w in meaningful):
|
||||||
|
output.append(normalized)
|
||||||
|
max_n = min(7, len(raw_words) + 1)
|
||||||
|
for n in range(2, max_n):
|
||||||
|
for i in range(0, len(raw_words) - n + 1):
|
||||||
|
chunk = raw_words[i : i + n]
|
||||||
|
if not any(w in meaningful for w in chunk):
|
||||||
|
continue
|
||||||
|
phrase = " ".join(chunk).strip()
|
||||||
|
if len(phrase) >= 3:
|
||||||
|
output.append(phrase)
|
||||||
|
for word in meaningful:
|
||||||
|
# Keep meaningful single-token gene/family symbols such as SPL, WRKY, GmP450.
|
||||||
|
if len(word) >= 3 or any(ch.isdigit() for ch in word):
|
||||||
|
output.append(word)
|
||||||
|
output.sort(key=lambda x: (len(x.split()) < 2, -len(x)))
|
||||||
|
return list(dict.fromkeys(output))[:80]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_name(value: Any) -> str:
|
||||||
|
text = str(value or "").lower()
|
||||||
|
text = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", text)
|
||||||
|
return " ".join(text.split())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fulltext_query(value: Any) -> str:
|
||||||
|
tokens = []
|
||||||
|
for token in TOKEN_RE.findall(str(value or "")):
|
||||||
|
token = token.lower()
|
||||||
|
if len(token) >= 3 and token not in STOPWORDS:
|
||||||
|
tokens.append(token)
|
||||||
|
return " OR ".join(list(dict.fromkeys(tokens))[:24])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def query_terms(question: str) -> List[str]:
|
||||||
|
output = []
|
||||||
|
for token in TOKEN_RE.findall(question):
|
||||||
|
low = token.lower()
|
||||||
|
if len(low) >= 3 and low not in STOPWORDS:
|
||||||
|
output.append(low)
|
||||||
|
return list(dict.fromkeys(output))[:30]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def layers(include_candidate: bool) -> List[str]:
|
||||||
|
output = [
|
||||||
|
"main_kg",
|
||||||
|
"background_relations",
|
||||||
|
"method_relations",
|
||||||
|
"methodology_relations",
|
||||||
|
"dataset_metadata",
|
||||||
|
]
|
||||||
|
if include_candidate:
|
||||||
|
output.append("candidate_review")
|
||||||
|
return output
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self.sqlite is not None:
|
||||||
|
self.sqlite.close()
|
||||||
|
self.sqlite = None
|
||||||
|
self.driver.close()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Reusable retrieval components for Plant SRT QA."""
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Iterable, List
|
||||||
|
|
||||||
|
|
||||||
|
VALID_RELATION_GROUPS = {
|
||||||
|
"abiotic_stress_tolerance",
|
||||||
|
"catalog_count",
|
||||||
|
"chromosomal_location",
|
||||||
|
"classification",
|
||||||
|
"compound_effect",
|
||||||
|
"developmental_regulation",
|
||||||
|
"disease_resistance",
|
||||||
|
"enzyme_activity",
|
||||||
|
"expression_condition",
|
||||||
|
"expression_time",
|
||||||
|
"expression_tissue",
|
||||||
|
"family_membership",
|
||||||
|
"gene_structure",
|
||||||
|
"hormone_response",
|
||||||
|
"metabolic_change",
|
||||||
|
"motif_domain",
|
||||||
|
"orthology_homology",
|
||||||
|
"pathogen_response",
|
||||||
|
"pathway_participation",
|
||||||
|
"phenotype_effect",
|
||||||
|
"physiological_measurement",
|
||||||
|
"protein_interaction",
|
||||||
|
"protein_property",
|
||||||
|
"regulatory_interaction",
|
||||||
|
"stress_response",
|
||||||
|
"subcellular_localization",
|
||||||
|
"treatment_or_stress",
|
||||||
|
"yield_trait",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Conservative fallback taxonomy for relation_norm values whose stored
|
||||||
|
# relation_category is empty, invalid, or too generic for cascaded path search.
|
||||||
|
# This table intentionally covers only stable relation families; it does not
|
||||||
|
# change path scoring, bridge scoring, top_k, native_chain_top_k, or planner logic.
|
||||||
|
RELATION_TO_GROUP = {
|
||||||
|
"is_a": "classification",
|
||||||
|
"encodes": "gene_structure",
|
||||||
|
"member_of": "family_membership",
|
||||||
|
"is_member_of": "family_membership",
|
||||||
|
"belongs_to": "family_membership",
|
||||||
|
"belongs_to_family": "family_membership",
|
||||||
|
"interacts_with": "protein_interaction",
|
||||||
|
"forms_complex_with": "protein_interaction",
|
||||||
|
"regulates": "regulatory_interaction",
|
||||||
|
"activates": "regulatory_interaction",
|
||||||
|
"promotes": "regulatory_interaction",
|
||||||
|
"controls": "regulatory_interaction",
|
||||||
|
"inhibits": "regulatory_interaction",
|
||||||
|
"negatively_regulates": "regulatory_interaction",
|
||||||
|
"represses": "regulatory_interaction",
|
||||||
|
"suppresses": "regulatory_interaction",
|
||||||
|
"involved_in": "pathway_participation",
|
||||||
|
"participates_in": "pathway_participation",
|
||||||
|
"responds_to": "stress_response",
|
||||||
|
"induced_by": "stress_response",
|
||||||
|
"treated_with": "treatment_or_stress",
|
||||||
|
"expressed_in": "expression_tissue",
|
||||||
|
"relative_expression_in": "expression_tissue",
|
||||||
|
"localizes_to": "subcellular_localization",
|
||||||
|
"localized_to": "subcellular_localization",
|
||||||
|
"located_in": "subcellular_localization",
|
||||||
|
"is_orthologous_to": "orthology_homology",
|
||||||
|
"orthologous_to": "orthology_homology",
|
||||||
|
"has_homology_to": "orthology_homology",
|
||||||
|
"homologous_to": "orthology_homology",
|
||||||
|
"contains_domain": "motif_domain",
|
||||||
|
"has_domain": "motif_domain",
|
||||||
|
"contains_motif": "motif_domain",
|
||||||
|
"has_motif": "motif_domain",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_relation_name(value: Any) -> str:
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
text = re.sub(r"[^a-z0-9]+", "_", text)
|
||||||
|
return "_".join(part for part in text.split("_") if part)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_relation_group(relation: Any, existing_group: Any = "") -> str:
|
||||||
|
group = normalize_relation_name(existing_group)
|
||||||
|
if group in VALID_RELATION_GROUPS:
|
||||||
|
return group
|
||||||
|
return RELATION_TO_GROUP.get(normalize_relation_name(relation), group)
|
||||||
|
|
||||||
|
|
||||||
|
def relation_names_for_groups(groups: Iterable[str]) -> List[str]:
|
||||||
|
wanted = {normalize_relation_name(group) for group in groups or []}
|
||||||
|
names = [
|
||||||
|
relation
|
||||||
|
for relation, group in RELATION_TO_GROUP.items()
|
||||||
|
if normalize_relation_name(group) in wanted
|
||||||
|
]
|
||||||
|
return sorted(dict.fromkeys(names))
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from gfa_core.mas import dual_kb_qa
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
status_code = 200
|
||||||
|
content = b"{}"
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
def __init__(self, iter_lines_calls=None):
|
||||||
|
self.iter_lines_calls = iter_lines_calls
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"success": True, "answer": "ok", "sources": []}
|
||||||
|
|
||||||
|
def iter_lines(self, chunk_size=None, decode_unicode=True):
|
||||||
|
if self.iter_lines_calls is not None:
|
||||||
|
self.iter_lines_calls.append((chunk_size, decode_unicode))
|
||||||
|
payload = json.dumps({"success": True, "answer": "ok", "sources": []})
|
||||||
|
yield "event: complete"
|
||||||
|
yield f"data: {payload}"
|
||||||
|
yield ""
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _force_v1173(monkeypatch):
|
||||||
|
monkeypatch.setattr(dual_kb_qa, "DUAL_KB_QA_ROUTE", "qa-v1-1-73")
|
||||||
|
monkeypatch.delenv("QA_V1_1_73_TIMEOUT", raising=False)
|
||||||
|
monkeypatch.delenv("QA_V1_1_73_STREAM_IDLE_TIMEOUT", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_stream_uses_10_second_connect_and_900_second_read_timeout(monkeypatch):
|
||||||
|
_force_v1173(monkeypatch)
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def fake_post(*args, **kwargs):
|
||||||
|
captured.append(kwargs["timeout"])
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
|
||||||
|
|
||||||
|
dual_kb_qa.query_dual_kb_qa("question")
|
||||||
|
|
||||||
|
assert captured == [(10.0, 900.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_stream_honors_environment_timeout_above_150_seconds(monkeypatch):
|
||||||
|
_force_v1173(monkeypatch)
|
||||||
|
monkeypatch.setenv("QA_V1_1_73_TIMEOUT", "300")
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def fake_post(*args, **kwargs):
|
||||||
|
captured.append(kwargs["timeout"])
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
|
||||||
|
|
||||||
|
dual_kb_qa.query_dual_kb_qa("question")
|
||||||
|
|
||||||
|
assert captured == [(10.0, 300.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1173_requests_answer_response_detail(monkeypatch):
|
||||||
|
_force_v1173(monkeypatch)
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def fake_post(*args, **kwargs):
|
||||||
|
captured.append(kwargs["json"])
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
|
||||||
|
|
||||||
|
dual_kb_qa.query_dual_kb_qa("question")
|
||||||
|
|
||||||
|
assert captured[0]["response_detail"] == "answer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1173_stream_reads_one_byte_chunks(monkeypatch):
|
||||||
|
_force_v1173(monkeypatch)
|
||||||
|
iter_lines_calls = []
|
||||||
|
|
||||||
|
def fake_post(*args, **kwargs):
|
||||||
|
return FakeResponse(iter_lines_calls)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
|
||||||
|
|
||||||
|
list(dual_kb_qa.stream_dual_kb_qa("question"))
|
||||||
|
|
||||||
|
assert iter_lines_calls == [(1, True)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_uses_configurable_idle_timeout(monkeypatch):
|
||||||
|
_force_v1173(monkeypatch)
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def fake_post(*args, **kwargs):
|
||||||
|
captured.append(kwargs["timeout"])
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
|
||||||
|
|
||||||
|
list(dual_kb_qa.stream_dual_kb_qa("question"))
|
||||||
|
assert captured == [(10.0, 45.0)]
|
||||||
|
|
||||||
|
monkeypatch.setenv("QA_V1_1_73_STREAM_IDLE_TIMEOUT", "70")
|
||||||
|
captured.clear()
|
||||||
|
list(dual_kb_qa.stream_dual_kb_qa("question"))
|
||||||
|
assert captured == [(10.0, 70.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_timeout_is_reported_as_request_timeout(monkeypatch):
|
||||||
|
_force_v1173(monkeypatch)
|
||||||
|
|
||||||
|
def fake_post(*args, **kwargs):
|
||||||
|
raise requests.ReadTimeout("read timed out")
|
||||||
|
|
||||||
|
monkeypatch.setattr(dual_kb_qa.requests, "post", fake_post)
|
||||||
|
|
||||||
|
frames = list(dual_kb_qa.stream_dual_kb_qa("question"))
|
||||||
|
|
||||||
|
assert len(frames) == 1
|
||||||
|
assert "event: error" in frames[0]
|
||||||
|
assert '"status": 504' in frames[0]
|
||||||
|
assert '"error_code": "request_timeout"' in frames[0]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
START = ROOT / "scripts" / "start_qa_v1_1_73_7920.sh"
|
||||||
|
SERVER = ROOT / "scripts" / "qa_v1_1_73_server.py"
|
||||||
|
PACKAGE = ROOT / "services" / "qa_v1_1_73" / "src" / "rag" / "qa_v1_1_73"
|
||||||
|
PLANTAI_MAIN = ROOT / "apps" / "web" / "vue3" / "main.py"
|
||||||
|
|
||||||
|
|
||||||
|
def test_formal_qa_sources_are_versioned_in_plantai():
|
||||||
|
assert SERVER.is_file()
|
||||||
|
assert (PACKAGE / "api.py").is_file()
|
||||||
|
assert (PACKAGE / "assertion_anchored_graph_retriever.py").is_file()
|
||||||
|
assert (PACKAGE / "agentic_hop_gate.py").is_file()
|
||||||
|
assert "sandbox" not in (PACKAGE / "__init__.py").read_text(encoding="utf-8").lower()
|
||||||
|
assert "Sandbox" not in (PACKAGE / "api.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_7920_startup_has_no_sandbox_dependency():
|
||||||
|
text = START.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "/sandboxes/" not in text
|
||||||
|
assert 'APP_MODULE="scripts.qa_v1_1_73_server:app"' in text
|
||||||
|
assert 'QA_V1_BOUNDED_NEO4J_TIMEOUT="8"' in text
|
||||||
|
assert 'export NEO4J_URI="bolt://127.0.0.1:18788"' in text
|
||||||
|
assert 'export V15_QDRANT_URL="http://127.0.0.1:26333"' in text
|
||||||
|
assert 'export V15_QDRANT_COLLECTION="plant_srt_direct_v1_bge_m3_20260702"' in text
|
||||||
|
assert "--host 127.0.0.1" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidecar_validation_has_a_fixed_admin_endpoint():
|
||||||
|
text = PLANTAI_MAIN.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert '@app.post("/api/admin/validate-qa-sidecar")' in text
|
||||||
|
assert '_validate_admin_ops_request(request, payload, "validate-qa-sidecar")' in text
|
||||||
|
assert 'PROJECT_ROOT / "scripts" / "validate_qa_v1_1_73_sidecar.sh"' in text
|
||||||
|
assert '@app.post("/api/admin/stop-qa-sidecar")' in text
|
||||||
|
assert '_validate_admin_ops_request(request, payload, "stop-qa-sidecar")' in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidecar_status_has_a_fixed_admin_endpoint():
|
||||||
|
text = PLANTAI_MAIN.read_text(encoding="utf-8")
|
||||||
|
assert '@app.post("/api/admin/qa-sidecar-status")' in text
|
||||||
|
assert '_validate_admin_ops_request(request, payload, "qa-sidecar-status")' in text
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
FRONTEND_DIR = Path(__file__).resolve().parents[2] / "apps" / "web" / "vue3"
|
||||||
|
PAGE_NAMES = ("index.html", "index_ml.html")
|
||||||
|
PAGE_SOURCES = {
|
||||||
|
name: (FRONTEND_DIR / name).read_text(encoding="utf-8")
|
||||||
|
for name in PAGE_NAMES
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _source(name: str) -> str:
|
||||||
|
return PAGE_SOURCES[name]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", PAGE_NAMES)
|
||||||
|
def test_rag_mode_uses_streaming_request(name: str) -> None:
|
||||||
|
source = _source(name)
|
||||||
|
start = source.index("// V2: RAG模式")
|
||||||
|
end = source.index("} catch (err)", start)
|
||||||
|
rag_block = source[start:end]
|
||||||
|
|
||||||
|
assert "requestRagWithProgress(" in rag_block
|
||||||
|
assert "fetchJsonWithTimeout('/api/agent/rag'" not in rag_block
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", PAGE_NAMES)
|
||||||
|
def test_rag_stream_timeout_is_reset_after_each_received_chunk(name: str) -> None:
|
||||||
|
source = _source(name)
|
||||||
|
start = source.index("const readRagEventStream")
|
||||||
|
end = source.index("const requestRagWithProgress", start)
|
||||||
|
stream_block = source[start:end]
|
||||||
|
read_position = stream_block.index("const { value, done } = await reader.read();")
|
||||||
|
|
||||||
|
assert "const resetIdleTimer = () =>" in stream_block
|
||||||
|
assert stream_block.index("resetIdleTimer();", read_position) > read_position
|
||||||
|
assert "知识库检索连接空闲超时" in stream_block
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
MAIN = ROOT / "apps" / "web" / "vue3" / "main.py"
|
||||||
|
MAIN_SOURCE = MAIN.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _rag_routes() -> str:
|
||||||
|
start = MAIN_SOURCE.index('@app.post("/api/agent/rag")')
|
||||||
|
end = MAIN_SOURCE.index("# ==================== DiagnosisAgent API", start)
|
||||||
|
return MAIN_SOURCE[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_proxy_has_no_inline_150_second_timeout() -> None:
|
||||||
|
routes = _rag_routes()
|
||||||
|
|
||||||
|
assert "timeout=150.0" not in routes
|
||||||
|
assert "complete QA exceeded 150 seconds" not in routes
|
||||||
|
assert "完整知识图谱问答超过 150 秒" not in routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_rag_stream_proxy_preserves_upstream_sse_frames() -> None:
|
||||||
|
routes = _rag_routes()
|
||||||
|
|
||||||
|
assert "for frame in stream_dual_kb_qa(" in routes
|
||||||
|
assert "yield frame" in routes
|
||||||
|
assert 'media_type="text/event-stream"' in routes
|
||||||
Reference in New Issue
Block a user