study-buddy / app /services /nucleic_evidence.py
GitHub Actions
deploy d092bea3608b7a29952f16357fda39b7a29e399b
2e818da
Raw
History Blame Contribute Delete
7.05 kB
from __future__ import annotations
import asyncio
import logging
import os
from app.schemas.visual_lesson import EvidenceClaim, EvidenceSource, VisualizationRequest
from app.services.visual_evidence import VisualEvidenceResolver, _clean
CANONICAL_NUCLEIC_SOURCES = [
EvidenceSource(
source_id="builtin:nucleic-chemistry",
origin="builtin",
title="NCBI Bookshelf: Molecular Biology of the Cell — DNA structure",
url="https://www.ncbi.nlm.nih.gov/books/NBK26821/",
excerpt="Canonical reference metadata for nucleotides, antiparallel strands, complementary base pairing, and the sugar-phosphate backbone.",
authority="canonical",
),
EvidenceSource(
source_id="builtin:nucleic-forms",
origin="builtin",
title="RCSB PDB-101: Nucleic acid structure",
url="https://pdb101.rcsb.org/learn/guide-to-understanding-pdb-data/introduction",
excerpt="Canonical reference metadata for archived nucleic-acid structures and common DNA/RNA structural representations.",
authority="canonical",
),
]
logger = logging.getLogger(__name__)
class NucleicEvidenceResolver:
"""Resolve only context that can affect the requested nucleic visualization."""
def __init__(self, base: VisualEvidenceResolver | None = None) -> None:
self.base = base or VisualEvidenceResolver()
@staticmethod
def _needs_context_research(request: VisualizationRequest) -> bool:
prompt_text = request.prompt.lower()
explicit_context_request = any(
term in prompt_text
for term in ("paper", "selected", "according to", "reported", "study", "figure", "this molecule", "this structure")
)
# Surrounding PDF text is transport context, not an instruction to use
# the active paper. Named canonical molecules must not fan out into an
# unrelated project merely because a document is open.
named_canonical = any(
term in prompt_text
for term in ("adenine", "cytosine", "guanine", "thymine", "uracil")
)
if named_canonical and not explicit_context_request:
return False
return bool(
explicit_context_request
or request.selection_text
or request.selection_snippets
or request.selection_image_base64
)
@staticmethod
def _dedupe(sources: list[EvidenceSource]) -> list[EvidenceSource]:
output: list[EvidenceSource] = []
seen: set[tuple[str, str]] = set()
limits = {"selection": 4, "project": 4, "web": 3, "builtin": 2, "database": 3}
counts: dict[str, int] = {}
for source in sources:
key = (source.source_id, source.excerpt[:240])
if key in seen or counts.get(source.origin, 0) >= limits[source.origin]:
continue
seen.add(key)
counts[source.origin] = counts.get(source.origin, 0) + 1
output.append(source)
return output
async def resolve_context(self, project_id: str, request: VisualizationRequest) -> tuple[list[EvidenceSource], list[EvidenceClaim], list[str]]:
warnings: list[str] = []
sources = self.base._selection_sources(request)
if self._needs_context_research(request):
queries = list(dict.fromkeys(filter(None, [
_clean(request.prompt),
_clean(request.selection_text),
"DNA RNA nucleotide nucleobase molecular structure",
])))
logger.info("Nucleic evidence contextual fan-out request=%s queries=%d", request.request_id, len(queries))
project_task = (
asyncio.create_task(self.base._project_search(project_id, queries, request.active_document_ids))
if request.project_context_enabled else None
)
image_task = asyncio.create_task(self.base._image_source(request))
web_rows: list[dict] = []
if os.getenv("TAVILY_API_KEY"):
try:
search = self.base._web_search or self.base._default_web_search
batches = await asyncio.gather(*(search(query) for query in queries[:2]), return_exceptions=True)
for batch in batches:
if isinstance(batch, list):
web_rows.extend(batch)
except Exception:
warnings.append("Live contextual research failed; canonical and official database evidence were still used.")
if project_task is not None:
try:
project_rows = await project_task
sources.extend(self.base._candidate_sources(project_rows, "project"))
except Exception:
warnings.append("Project context retrieval failed; canonical and official database evidence were still used.")
image_source = await image_task
if image_source:
sources.append(image_source)
sources.extend(self.base._candidate_sources(web_rows, "web"))
else:
logger.info(
"Nucleic evidence canonical-only request=%s active_documents=%d",
request.request_id,
len(request.active_document_ids),
)
sources.extend(CANONICAL_NUCLEIC_SOURCES)
sources = self._dedupe(sources)
claims = [
EvidenceClaim(
claim_id="nucleic-backbone",
text="Nucleic-acid strands have a repeating sugar-phosphate backbone and directional 5-prime and 3-prime ends.",
claim_type="standard_definition",
support_level="canonical",
source_ids=["builtin:nucleic-chemistry"],
),
EvidenceClaim(
claim_id="nucleic-pairing",
text="The visualization uses canonical A–T and G–C DNA pairing, or A–U and G–C RNA pairing.",
claim_type="standard_definition",
support_level="canonical",
source_ids=["builtin:nucleic-chemistry"],
),
EvidenceClaim(
claim_id="nucleic-forms",
text="A-, B-, and Z-DNA are shown with distinct canonical helix parameters; Z-DNA is left-handed.",
claim_type="standard_definition",
support_level="canonical",
source_ids=["builtin:nucleic-forms"],
),
]
# Retrieved passages remain planning context only. A literal selection
# anchor proves provenance, not that the passage supports a displayed
# molecular fact, so it is never promoted to a claim automatically.
logger.info(
"Nucleic evidence resolved request=%s sources=%d claims=%d warnings=%d",
request.request_id,
len(sources),
len(claims),
len(warnings),
)
return sources, claims, warnings