wip: qa7920 sidecar + rag stream timeout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
sj
2026-08-21 11:04:28 +08:00
co-authored by Claude Opus 5
parent b725a9c125
commit 90033214b4
66 changed files with 18990 additions and 68 deletions
+4
View File
@@ -30,3 +30,7 @@ Rplots.pdf
*.pyc
*.log
.gitignore
.worktrees/
extension_stages_lightweight/
services/*/logs/
*.bak_*
+282 -23
View File
@@ -25,6 +25,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
import hashlib
import os
import shlex
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
@@ -231,8 +232,17 @@ RNASEQ_BIN = str(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]:
"""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))
jobs = max(1, int(sample_parallel_jobs or 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))
if jobs * threads > total_cpu:
threads = max(1, total_cpu // jobs)
threads = min(threads, MAX_TOOL_THREADS)
return {"cpu": total_cpu, "sample_parallel_jobs": jobs, "threads_per_sample": threads}
@@ -378,6 +389,253 @@ def run_cutadapt(
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(
fastp_bin: Path,
r1: Path,
@@ -397,30 +655,22 @@ def run_fastp(
unqualified_percent_limit: int = 30,
n_base_limit: int = 5,
length_required: int = 30,
chunks: int = 1,
chunk_work_dir: Optional[Path] = None,
) -> Dict[str, Any]:
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, 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")
if chunks and int(chunks) > 1 and chunk_work_dir is not None:
return _run_fastp_chunked(
fastp_bin, r1, r2, out_r1, out_r2, html_report, json_report, log_file,
cpu, int(chunks), chunk_work_dir,
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,
)
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)
start_ts = time.time()
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("--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("--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("-p", "--prefix", default="", help="Output file prefix")
parser.add_argument("--adapter-r1", default="AGATCGGAAGAGC", help="Adapter sequence for read1")
@@ -786,6 +1037,12 @@ def main():
args = parser.parse_args()
thread_plan = resolve_thread_plan(args.cpu, args.sample_parallel_jobs, args.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()
input_dir = Path(args.input)
@@ -878,6 +1135,8 @@ def main():
json_report=analysis_dir / f"{sample_name}_fastp.json",
log_file=analysis_dir / f"{sample_name}_fastp.log",
cpu=threads_per_sample,
chunks=chunks_per_sample,
chunk_work_dir=analysis_dir / f".{sample_name}.chunks",
**fastp_params,
)
except RuntimeError as e:
+22 -16
View File
@@ -22322,11 +22322,20 @@ compare: [[flesh, peel]]`;
}
};
const readRagEventStream = async (url, options = {}, timeoutMs = 300000) => {
const readRagEventStream = async (url, options = {}, idleTimeoutMs = 45000) => {
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;
try {
resetIdleTimer();
const response = await fetch(url, { ...options, signal: controller.signal });
if (!response.ok) {
const error = new Error(`RAG流式请求失败:HTTP ${response.status}`);
@@ -22375,6 +22384,7 @@ compare: [[flesh, peel]]`;
while (true) {
const { value, done } = await reader.read();
if (!done && value && value.length) resetIdleTimer();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() || '';
@@ -22389,8 +22399,8 @@ compare: [[flesh, peel]]`;
}
return completedPayload;
} catch (err) {
if (err?.name === 'AbortError') {
const timeoutError = new Error('知识库检索超时(300秒未返回)。');
if (err?.name === 'AbortError' && idleTimedOut) {
const timeoutError = new Error(`知识库检索连接空闲超时(${Math.round(idleTimeoutMs / 1000)}秒未收到进度)。`);
timeoutError.code = 'request_timeout';
throw timeoutError;
}
@@ -22400,7 +22410,7 @@ compare: [[flesh, peel]]`;
}
};
const requestRagWithProgress = async (payload, timeoutMs = 300000) => {
const requestRagWithProgress = async (payload, idleTimeoutMs = 45000) => {
resetRagProgress();
const options = {
method: 'POST',
@@ -22408,14 +22418,14 @@ compare: [[flesh, peel]]`;
body: JSON.stringify(payload)
};
try {
return await readRagEventStream('/api/agent/rag/stream', options, timeoutMs);
return await readRagEventStream('/api/agent/rag/stream', options, idleTimeoutMs);
} catch (error) {
if (error?.code !== 'stream_unavailable') throw error;
applyRagProgressEvent({
stage: 'related_search_started',
message: '正在知识库中查找相关内容……'
});
return await fetchJsonWithTimeout('/api/agent/rag', options, timeoutMs, '知识库检索');
return await fetchJsonWithTimeout('/api/agent/rag', options, 900000, '知识库检索');
}
};
@@ -22611,15 +22621,11 @@ compare: [[flesh, peel]]`;
ragHistory.value.push({ role: 'user', content: userInput, time: new Date().toLocaleTimeString() });
// 调用当前 bbav5 后端 RAG 代理,随页面端口走同源地址(例如 7880)
const data = await fetchJsonWithTimeout('/api/agent/rag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: userInput,
mode: 'rag',
top_k: 10
})
}, 120000, '知识库检索');
const data = await requestRagWithProgress({
query: userInput,
mode: 'rag',
top_k: 10
});
const answer = normalizeRagAnswerForDisplay(data.success === false
? (data.message || '未找到相关信息')
: (data.response || data.answer || '未找到相关信息'), userInput);
+22 -16
View File
@@ -22196,11 +22196,20 @@ compare: [[flesh, peel]]`;
}
};
const readRagEventStream = async (url, options = {}, timeoutMs = 300000) => {
const readRagEventStream = async (url, options = {}, idleTimeoutMs = 45000) => {
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;
try {
resetIdleTimer();
const response = await fetch(url, { ...options, signal: controller.signal });
if (!response.ok) {
const error = new Error(`RAG流式请求失败:HTTP ${response.status}`);
@@ -22249,6 +22258,7 @@ compare: [[flesh, peel]]`;
while (true) {
const { value, done } = await reader.read();
if (!done && value && value.length) resetIdleTimer();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() || '';
@@ -22263,8 +22273,8 @@ compare: [[flesh, peel]]`;
}
return completedPayload;
} catch (err) {
if (err?.name === 'AbortError') {
const timeoutError = new Error('知识库检索超时(300秒未返回)。');
if (err?.name === 'AbortError' && idleTimedOut) {
const timeoutError = new Error(`知识库检索连接空闲超时(${Math.round(idleTimeoutMs / 1000)}秒未收到进度)。`);
timeoutError.code = 'request_timeout';
throw timeoutError;
}
@@ -22274,7 +22284,7 @@ compare: [[flesh, peel]]`;
}
};
const requestRagWithProgress = async (payload, timeoutMs = 300000) => {
const requestRagWithProgress = async (payload, idleTimeoutMs = 45000) => {
resetRagProgress();
const options = {
method: 'POST',
@@ -22282,14 +22292,14 @@ compare: [[flesh, peel]]`;
body: JSON.stringify(payload)
};
try {
return await readRagEventStream('/api/agent/rag/stream', options, timeoutMs);
return await readRagEventStream('/api/agent/rag/stream', options, idleTimeoutMs);
} catch (error) {
if (error?.code !== 'stream_unavailable') throw error;
applyRagProgressEvent({
stage: 'related_search_started',
message: '正在知识库中查找相关内容……'
});
return await fetchJsonWithTimeout('/api/agent/rag', options, timeoutMs, '知识库检索');
return await fetchJsonWithTimeout('/api/agent/rag', options, 900000, '知识库检索');
}
};
@@ -22485,15 +22495,11 @@ compare: [[flesh, peel]]`;
ragHistory.value.push({ role: 'user', content: userInput, time: new Date().toLocaleTimeString() });
// 调用当前 bbav5 后端 RAG 代理,随页面端口走同源地址
const data = await fetchJsonWithTimeout('/api/agent/rag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: userInput,
mode: 'rag',
top_k: 10
})
}, 120000, '知识库检索');
const data = await requestRagWithProgress({
query: userInput,
mode: 'rag',
top_k: 10
});
const answer = normalizeRagAnswerForDisplay(data.success === false
? (data.message || '未找到相关信息')
: (data.response || data.answer || '未找到相关信息'), userInput);
+156 -1
View File
@@ -13386,6 +13386,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):
@@ -24991,6 +25119,8 @@ async def rag_query(request: RAGQueryRequest):
基于文献知识库回答问题
"""
import requests
try:
from gfa_core.mas.dual_kb_qa import query_dual_kb_qa
@@ -25016,6 +25146,32 @@ async def rag_query(request: RAGQueryRequest):
payload["evidence_rag_plan"] = evidence_rag_plan
payload["augmented_query"] = augmented_query
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:
import traceback
augmented_query = request.query or ""
@@ -25048,7 +25204,6 @@ async def rag_query_stream(request: RAGQueryRequest):
for frame in stream_dual_kb_qa(
augmented_query,
top_k=request.top_k or 10,
timeout=300.0,
):
if frame.startswith("event: complete"):
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
View File
@@ -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_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"
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:
@@ -31,6 +34,35 @@ def _env_bool(name: str, default: bool) -> bool:
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:
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":
payload: dict[str, Any] = {
"question": question,
"response_detail": "answer",
"debug": False,
"use_llm": resolved_use_llm,
"chain_top_k": 3,
@@ -315,7 +348,7 @@ def query_dual_kb_qa(
graph_hops: int = 2,
graph_limit_nodes: int = 60,
graph_limit_edges: int = 100,
timeout: float = 240.0,
timeout: float = QA_V1_1_73_DEFAULT_TIMEOUT_SECONDS,
) -> dict[str, Any]:
question = str(question or "").strip()
if not question:
@@ -338,15 +371,15 @@ def query_dual_kb_qa(
effective_use_llm = bool(payload.get("use_llm"))
request_path = "chat" if effective_use_llm else "search"
request_timeout = (
max(timeout, float(os.environ.get("QA_V1_1_73_TIMEOUT", "300")))
transport_timeout = (
_non_stream_timeout(timeout)
if route == "qa-v1-1-73"
else timeout
else float(timeout)
)
response = requests.post(
dual_kb_endpoint(request_path),
json=payload,
timeout=request_timeout,
timeout=transport_timeout,
)
data = response.json() if response.content else {}
initial_detail = data.get("detail") if isinstance(data, dict) else None
@@ -359,7 +392,7 @@ def query_dual_kb_qa(
response = requests.post(
dual_kb_endpoint(request_path),
json=payload,
timeout=request_timeout,
timeout=transport_timeout,
)
data = response.json() if response.content else {}
if response.status_code >= 400:
@@ -401,7 +434,7 @@ def stream_dual_kb_qa(
graph_hops: int = 2,
graph_limit_nodes: int = 60,
graph_limit_edges: int = 100,
timeout: float = 300.0,
timeout: float | None = None,
) -> Iterator[str]:
"""Proxy truthful QA milestones and normalize the final V1.1.73 payload."""
question = str(question or "").strip()
@@ -426,17 +459,17 @@ def stream_dual_kb_qa(
graph_limit_nodes=graph_limit_nodes,
graph_limit_edges=graph_limit_edges,
)
request_timeout = (
max(timeout, float(os.environ.get("QA_V1_1_73_TIMEOUT", "300")))
transport_timeout = (
_stream_timeout(timeout)
if route == "qa-v1-1-73"
else timeout
else _positive_timeout(timeout, QA_V1_1_73_DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS)
)
try:
response = requests.post(
dual_kb_endpoint("chat/stream"),
json=payload,
stream=True,
timeout=(10.0, request_timeout),
timeout=transport_timeout,
)
except requests.Timeout as exc:
yield _sse_frame("error", {
@@ -475,7 +508,7 @@ def stream_dual_kb_qa(
event_name = "message"
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)
if line.startswith(":"):
yield ": keepalive\n\n"
+43
View File
@@ -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,
}
+84
View File
@@ -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
+52
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Versioned source package for the PlantAI full-graph QA service."""
+1
View File
@@ -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))
+129
View File
@@ -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]
+45
View File
@@ -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