pen-stack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pen_stack/__init__.py +2 -0
- pen_stack/_resources.py +34 -0
- pen_stack/active/__init__.py +20 -0
- pen_stack/active/acquire.py +165 -0
- pen_stack/active/brains.py +74 -0
- pen_stack/active/campaign.py +109 -0
- pen_stack/active/design.py +66 -0
- pen_stack/active/validate.py +104 -0
- pen_stack/adapt/__init__.py +14 -0
- pen_stack/adapt/finetune.py +33 -0
- pen_stack/adapt/ingest.py +86 -0
- pen_stack/adapt/pipeline.py +101 -0
- pen_stack/adapt/recalibrate.py +58 -0
- pen_stack/adapt/report.py +130 -0
- pen_stack/agent/__init__.py +1 -0
- pen_stack/agent/cite.py +175 -0
- pen_stack/agent/co_scientist.py +262 -0
- pen_stack/agent/epistemic.py +102 -0
- pen_stack/agent/guardrails.py +67 -0
- pen_stack/agent/mcp_server.py +215 -0
- pen_stack/agent/orchestrator.py +112 -0
- pen_stack/agent/orchestrator_live.py +56 -0
- pen_stack/agent/pen_agent.py +242 -0
- pen_stack/agent/scope.py +60 -0
- pen_stack/agent/tools.py +130 -0
- pen_stack/api/__init__.py +12 -0
- pen_stack/api/manifest.py +160 -0
- pen_stack/atlas/__init__.py +1 -0
- pen_stack/atlas/atlas.parquet +0 -0
- pen_stack/atlas/build_wtkb.py +80 -0
- pen_stack/atlas/crosslink.py +179 -0
- pen_stack/atlas/expand.py +190 -0
- pen_stack/atlas/guide_design.py +178 -0
- pen_stack/atlas/schema.py +59 -0
- pen_stack/atlas/scorecard.py +134 -0
- pen_stack/atlas/scorecard_v3.parquet +0 -0
- pen_stack/atlas/universe.py +75 -0
- pen_stack/atlas/universe_v3.parquet +0 -0
- pen_stack/atlas/variant_propose.py +155 -0
- pen_stack/atlas/writer_efficiency.py +184 -0
- pen_stack/atlas/writer_predict.py +229 -0
- pen_stack/atlas/writer_recommend.py +170 -0
- pen_stack/atlas/writer_verify.py +167 -0
- pen_stack/atlas/wtkb.parquet +0 -0
- pen_stack/bridge/__init__.py +1 -0
- pen_stack/bridge/activity.py +52 -0
- pen_stack/bridge/cli.py +65 -0
- pen_stack/bridge/fold_qc.py +53 -0
- pen_stack/bridge/guide_qc.py +87 -0
- pen_stack/bridge/ingest.py +139 -0
- pen_stack/bridge/offtarget.py +191 -0
- pen_stack/bridge/offtarget_energetics.py +105 -0
- pen_stack/bridge/ortholog_screen.py +73 -0
- pen_stack/bridge/pipeline.py +83 -0
- pen_stack/build/__init__.py +16 -0
- pen_stack/build/cloudlab.py +74 -0
- pen_stack/build/ingest.py +47 -0
- pen_stack/build/protocol.py +82 -0
- pen_stack/build/simlab.py +30 -0
- pen_stack/cli.py +126 -0
- pen_stack/data/__init__.py +1 -0
- pen_stack/data/encode.py +84 -0
- pen_stack/data/genome.py +71 -0
- pen_stack/data/ingest_chromatin.py +119 -0
- pen_stack/data/ingest_integration.py +112 -0
- pen_stack/data/ingest_safety_annot.py +201 -0
- pen_stack/data/ingest_trip.py +76 -0
- pen_stack/design/__init__.py +14 -0
- pen_stack/design/capsid_generate.py +62 -0
- pen_stack/design/generate.py +70 -0
- pen_stack/design/pareto.py +70 -0
- pen_stack/design/space.py +137 -0
- pen_stack/design/writer_variants.py +121 -0
- pen_stack/env/__init__.py +1 -0
- pen_stack/env/genome_writing_env.py +248 -0
- pen_stack/env/policies.py +94 -0
- pen_stack/graph/__init__.py +21 -0
- pen_stack/graph/build.py +133 -0
- pen_stack/graph/cell_types.py +58 -0
- pen_stack/graph/ingest.py +132 -0
- pen_stack/graph/query.py +148 -0
- pen_stack/graph/schema.py +100 -0
- pen_stack/loop/__init__.py +15 -0
- pen_stack/loop/continual.py +61 -0
- pen_stack/loop/cycle.py +84 -0
- pen_stack/loop/drift.py +41 -0
- pen_stack/mech/__init__.py +1 -0
- pen_stack/mech/classify_atlas.py +71 -0
- pen_stack/mech/pfam_whitelist.yaml +247 -0
- pen_stack/mech/whitelist.py +66 -0
- pen_stack/monitor/__init__.py +1 -0
- pen_stack/monitor/europepmc.py +32 -0
- pen_stack/monitor/run.py +57 -0
- pen_stack/monitor/triage.py +63 -0
- pen_stack/oracles/__init__.py +65 -0
- pen_stack/oracles/affinity.py +116 -0
- pen_stack/oracles/cache.py +53 -0
- pen_stack/oracles/energetics.py +33 -0
- pen_stack/oracles/genome.py +167 -0
- pen_stack/oracles/protein_design.py +136 -0
- pen_stack/oracles/reliability.py +64 -0
- pen_stack/oracles/rna.py +28 -0
- pen_stack/oracles/schema.py +77 -0
- pen_stack/oracles/status.py +123 -0
- pen_stack/oracles/structure.py +42 -0
- pen_stack/oracles/structure_run.py +76 -0
- pen_stack/oracles/vcell.py +74 -0
- pen_stack/planner/__init__.py +1 -0
- pen_stack/planner/ada_risk.py +64 -0
- pen_stack/planner/antipeg_oracle.py +75 -0
- pen_stack/planner/capsid_epitope_oracle.py +135 -0
- pen_stack/planner/cargo.py +56 -0
- pen_stack/planner/cargo_polish.py +146 -0
- pen_stack/planner/chromosome.py +106 -0
- pen_stack/planner/delivery.py +55 -0
- pen_stack/planner/delivery_constraints.py +110 -0
- pen_stack/planner/delivery_immune.py +61 -0
- pen_stack/planner/delivery_immunology.py +222 -0
- pen_stack/planner/delivery_predict.py +196 -0
- pen_stack/planner/delivery_vehicles.py +37 -0
- pen_stack/planner/genotoxicity_oracle.py +112 -0
- pen_stack/planner/immune_mhc2.py +154 -0
- pen_stack/planner/immune_profile.py +292 -0
- pen_stack/planner/innate_sensing.py +135 -0
- pen_stack/planner/multiplex.py +110 -0
- pen_stack/planner/optimize.py +278 -0
- pen_stack/planner/pipeline.py +87 -0
- pen_stack/planner/report.py +26 -0
- pen_stack/planner/router.py +57 -0
- pen_stack/planner/seroprevalence_oracle.py +92 -0
- pen_stack/planner/target_site.py +118 -0
- pen_stack/rag/__init__.py +1 -0
- pen_stack/rag/corpus.py +133 -0
- pen_stack/rag/embed.py +98 -0
- pen_stack/rag/ground.py +131 -0
- pen_stack/rag/index.py +53 -0
- pen_stack/rag/llm.py +215 -0
- pen_stack/rag/qa.py +105 -0
- pen_stack/rag/retrieve.py +48 -0
- pen_stack/rules/__init__.py +9 -0
- pen_stack/rules/evaluators.py +318 -0
- pen_stack/rules/loader.py +31 -0
- pen_stack/rules/schema.py +99 -0
- pen_stack/rules/solver.py +43 -0
- pen_stack/rules/spec.py +78 -0
- pen_stack/safety/__init__.py +21 -0
- pen_stack/safety/audit.py +90 -0
- pen_stack/safety/gate.py +58 -0
- pen_stack/safety/pfam_scan.py +157 -0
- pen_stack/safety/policy.py +69 -0
- pen_stack/safety/redteam.py +71 -0
- pen_stack/safety/registry.py +255 -0
- pen_stack/safety/screen.py +59 -0
- pen_stack/safety/standards.py +141 -0
- pen_stack/score/__init__.py +1 -0
- pen_stack/score/recalibrate.py +77 -0
- pen_stack/score/therapeutic.py +85 -0
- pen_stack/server/__init__.py +1 -0
- pen_stack/server/api.py +647 -0
- pen_stack/spec/__init__.py +18 -0
- pen_stack/spec/clarify.py +42 -0
- pen_stack/spec/extract.py +406 -0
- pen_stack/spec/resolvers/__init__.py +17 -0
- pen_stack/spec/resolvers/cell.py +51 -0
- pen_stack/spec/resolvers/chem.py +32 -0
- pen_stack/spec/resolvers/feature.py +36 -0
- pen_stack/spec/resolvers/gene.py +43 -0
- pen_stack/spec/resolvers/locus.py +29 -0
- pen_stack/spec/resolvers/phenotype.py +37 -0
- pen_stack/spec/satisfy.py +114 -0
- pen_stack/spec/service.py +33 -0
- pen_stack/spec/writespec.py +252 -0
- pen_stack/twin/__init__.py +14 -0
- pen_stack/twin/calibrate.py +61 -0
- pen_stack/twin/data/__init__.py +12 -0
- pen_stack/twin/data/position_effect.py +245 -0
- pen_stack/twin/mechanistic.py +147 -0
- pen_stack/twin/outcome.py +132 -0
- pen_stack/twin/position_effect.py +454 -0
- pen_stack/ui/__init__.py +1 -0
- pen_stack/ui/app.py +713 -0
- pen_stack/validate/__init__.py +1 -0
- pen_stack/validate/adapt_demo.py +69 -0
- pen_stack/validate/agent_eval.py +117 -0
- pen_stack/validate/bench_adversarial_tasks.py +118 -0
- pen_stack/validate/bench_coscientist_tasks.py +60 -0
- pen_stack/validate/bench_graph_tasks.py +64 -0
- pen_stack/validate/bench_rule_tasks.py +84 -0
- pen_stack/validate/bench_trust_tasks.py +92 -0
- pen_stack/validate/bench_writetype_tasks.py +101 -0
- pen_stack/validate/blind_gsh_discovery.py +261 -0
- pen_stack/validate/cargo_directionality.py +57 -0
- pen_stack/validate/closed_loop.py +63 -0
- pen_stack/validate/durability_baselines.py +185 -0
- pen_stack/validate/experiment_design.py +65 -0
- pen_stack/validate/expr_controls.py +39 -0
- pen_stack/validate/forward_hypotheses.py +104 -0
- pen_stack/validate/generative_design.py +62 -0
- pen_stack/validate/guide_qc_demo.py +69 -0
- pen_stack/validate/heldout_celltype_expr.py +32 -0
- pen_stack/validate/immune_calibration.py +133 -0
- pen_stack/validate/intent_specification.py +82 -0
- pen_stack/validate/known_biology_expr.py +38 -0
- pen_stack/validate/offtarget_energetics_eval.py +144 -0
- pen_stack/validate/out_of_scope_refusal.py +82 -0
- pen_stack/validate/outcome_calibration.py +194 -0
- pen_stack/validate/outcome_prediction.py +76 -0
- pen_stack/validate/paper3_benchmark.py +165 -0
- pen_stack/validate/paper4_real_validation.py +144 -0
- pen_stack/validate/paper4_validation.py +82 -0
- pen_stack/validate/protocol_safety.py +62 -0
- pen_stack/validate/safety_screening.py +72 -0
- pen_stack/validate/selective_prediction.py +104 -0
- pen_stack/validate/seq_vs_measured.py +134 -0
- pen_stack/validate/target_site_controls.py +65 -0
- pen_stack/validate/uncertainty_eval.py +244 -0
- pen_stack/validate/ungrounded_baseline.py +234 -0
- pen_stack/validate/within_locus_ranking.py +84 -0
- pen_stack/validate/writer_recovery.py +91 -0
- pen_stack/verify/__init__.py +5 -0
- pen_stack/verify/proof.py +206 -0
- pen_stack/verify/schema.py +53 -0
- pen_stack/verify/service.py +191 -0
- pen_stack/web/__init__.py +18 -0
- pen_stack/web/guide.py +110 -0
- pen_stack/web/llm.py +393 -0
- pen_stack/web/llm_provider.py +119 -0
- pen_stack/web/router.py +119 -0
- pen_stack/web/server.py +96 -0
- pen_stack/web/tools.py +197 -0
- pen_stack/wgenome/__init__.py +1 -0
- pen_stack/wgenome/chromatin_seq.py +83 -0
- pen_stack/wgenome/durability.py +108 -0
- pen_stack/wgenome/export_tracks.py +52 -0
- pen_stack/wgenome/features.py +82 -0
- pen_stack/wgenome/genotoxic_blocklist.py +88 -0
- pen_stack/wgenome/gsh_baseline.py +154 -0
- pen_stack/wgenome/mesh_features.py +61 -0
- pen_stack/wgenome/offtarget_assay.py +80 -0
- pen_stack/wgenome/offtarget_bridge.py +47 -0
- pen_stack/wgenome/offtarget_cast.py +97 -0
- pen_stack/wgenome/offtarget_data.py +148 -0
- pen_stack/wgenome/offtarget_enumerate.py +274 -0
- pen_stack/wgenome/offtarget_integrase.py +155 -0
- pen_stack/wgenome/offtarget_nuclease.py +123 -0
- pen_stack/wgenome/offtarget_paste.py +41 -0
- pen_stack/wgenome/offtarget_predict.py +282 -0
- pen_stack/wgenome/ood.py +135 -0
- pen_stack/wgenome/providers.py +278 -0
- pen_stack/wgenome/safety.py +69 -0
- pen_stack/wgenome/structure3d.py +212 -0
- pen_stack/wgenome/uncertainty.py +250 -0
- pen_stack/wgenome/writability.py +72 -0
- pen_stack-0.1.0.dist-info/METADATA +401 -0
- pen_stack-0.1.0.dist-info/RECORD +259 -0
- pen_stack-0.1.0.dist-info/WHEEL +5 -0
- pen_stack-0.1.0.dist-info/entry_points.txt +3 -0
- pen_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pen_stack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Target-site / PAM / att-site availability filter.
|
|
2
|
+
|
|
3
|
+
A **sequence-computable hard filter** on reachability: for a candidate site sequence, does the writer family
|
|
4
|
+
carry the *targeting element* its mechanism physically requires? No usable element → the writer is unreachable
|
|
5
|
+
there and is REJECTED. This extends the WT-KB reachability tier with a sequence-level mechanistic check,
|
|
6
|
+
"the LLM may propose a writer, but if the physics says it cannot engage this site, the funnel rejects it."
|
|
7
|
+
|
|
8
|
+
Requirement per family (configs/target_sites.yaml):
|
|
9
|
+
* ``pam``, a PAM motif must occur in the window (Cas9 NGG / Cas12a TTTV / CAST GTN).
|
|
10
|
+
* ``core_dinuc``, a programmable bipartite recombinase needs its central core dinucleotide (bridge/seek CT).
|
|
11
|
+
* ``att_site``, a serine integrase needs an attB/attP (pseudo-)att; a naive genomic window has none, so
|
|
12
|
+
it REJECTS unless a landing pad was pre-installed (the key mechanistic reject).
|
|
13
|
+
* ``pe_installable``, a PE-integrase installs its own att, so the site is broadly reachable (available + note).
|
|
14
|
+
|
|
15
|
+
These are SCREENS, not activity guarantees (relaxed-PAM engineered variants especially); reported with a
|
|
16
|
+
confidence. The point is the hard *negative*: a physically impossible writer, site pairing is rejected.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from pen_stack._resources import resource
|
|
26
|
+
|
|
27
|
+
# IUPAC nucleotide → regex character class (for PAM / att-core expansion).
|
|
28
|
+
_IUPAC = {"A": "A", "C": "C", "G": "G", "T": "T", "R": "[AG]", "Y": "[CT]", "S": "[GC]", "W": "[AT]",
|
|
29
|
+
"K": "[GT]", "M": "[AC]", "B": "[CGT]", "D": "[AGT]", "H": "[ACT]", "V": "[ACG]", "N": "[ACGT]"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@lru_cache(maxsize=1)
|
|
33
|
+
def _cfg() -> dict:
|
|
34
|
+
return yaml.safe_load(resource("configs/target_sites.yaml").read_text(encoding="utf-8"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _clean(seq: str) -> str:
|
|
38
|
+
return re.sub(r"[^ACGT]", "", (seq or "").upper())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def iupac_to_regex(motif: str) -> str:
|
|
42
|
+
return "".join(_IUPAC.get(b, b) for b in motif.upper())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_motif(seq: str, motif: str) -> list[int]:
|
|
46
|
+
"""0-based start positions of an IUPAC motif (overlapping) in a cleaned sequence."""
|
|
47
|
+
s = _clean(seq)
|
|
48
|
+
pat = iupac_to_regex(motif)
|
|
49
|
+
return [m.start() for m in re.finditer(f"(?=({pat}))", s)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def target_site_available(family: str, seq: str, installed_att: bool = False) -> dict:
|
|
53
|
+
"""Is the writer family's required targeting element present in ``seq``? Returns a structured verdict.
|
|
54
|
+
|
|
55
|
+
``installed_att=True`` declares a pre-installed landing pad (so a serine/PE integrase becomes reachable
|
|
56
|
+
deterministically). The result is a HARD filter input: ``available=False`` → reject the writer at this site.
|
|
57
|
+
"""
|
|
58
|
+
fams = _cfg()["families"]
|
|
59
|
+
fam = fams.get(family)
|
|
60
|
+
s = _clean(seq)
|
|
61
|
+
if fam is None:
|
|
62
|
+
# unknown family: do not silently reject; report not-checkable (reachability falls back to WT-KB tier)
|
|
63
|
+
return {"family": family, "available": True, "checked": False,
|
|
64
|
+
"reason": "no target-site rule for this family; reachability deferred to WT-KB tier",
|
|
65
|
+
"confidence": "none"}
|
|
66
|
+
|
|
67
|
+
req = fam["requirement"]
|
|
68
|
+
conf = fam.get("confidence", "inferred")
|
|
69
|
+
|
|
70
|
+
if req == "pam":
|
|
71
|
+
hits = find_motif(s, fam["pam"])
|
|
72
|
+
ok = len(hits) > 0
|
|
73
|
+
out = {"family": family, "requirement": "pam", "pam": fam["pam"], "n_pam_sites": len(hits),
|
|
74
|
+
"available": ok, "checked": True, "confidence": conf,
|
|
75
|
+
"reason": (f"{len(hits)} {fam['pam']} PAM site(s) present" if ok
|
|
76
|
+
else f"no {fam['pam']} PAM in the window, writer cannot target here (reject)")}
|
|
77
|
+
if "insertion_offset_bp" in fam:
|
|
78
|
+
out["insertion_offset_bp"] = fam["insertion_offset_bp"]
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
if req == "core_dinuc":
|
|
82
|
+
hits = find_motif(s, fam["core_dinuc"])
|
|
83
|
+
ok = len(hits) > 0
|
|
84
|
+
return {"family": family, "requirement": "core_dinuc", "core": fam["core_dinuc"],
|
|
85
|
+
"n_core_sites": len(hits), "available": ok, "checked": True, "confidence": conf,
|
|
86
|
+
"reason": (f"central {fam['core_dinuc']} core present ({len(hits)} site(s)); loops reprogrammable"
|
|
87
|
+
if ok else f"no {fam['core_dinuc']} core in the window, bipartite recombinase "
|
|
88
|
+
"has no recombination point (reject)")}
|
|
89
|
+
|
|
90
|
+
if req == "att_site":
|
|
91
|
+
if installed_att:
|
|
92
|
+
return {"family": family, "requirement": "att_site", "available": True, "checked": True,
|
|
93
|
+
"confidence": conf, "installed_att": True,
|
|
94
|
+
"reason": "pre-installed att landing pad declared, integrase reachable deterministically"}
|
|
95
|
+
present = [m for m in fam.get("att_motifs", []) if iupac_to_regex(m) and re.search(iupac_to_regex(m), s)]
|
|
96
|
+
ok = bool(present)
|
|
97
|
+
return {"family": family, "requirement": "att_site", "available": ok, "checked": True,
|
|
98
|
+
"confidence": conf, "att_motifs_found": present,
|
|
99
|
+
"reason": ("a native (pseudo-)att core is present" if ok else
|
|
100
|
+
"no attB/attP (pseudo-)att in the window, serine integrase needs a pre-installed "
|
|
101
|
+
"landing pad; unreachable at a naive site (reject)")}
|
|
102
|
+
|
|
103
|
+
if req == "pe_installable":
|
|
104
|
+
return {"family": family, "requirement": "pe_installable", "available": True, "checked": True,
|
|
105
|
+
"confidence": conf,
|
|
106
|
+
"reason": "PE installs the att beacon at the chosen site, broadly reachable (no native motif "
|
|
107
|
+
"required; the install step is PE-bounded, per the WT-KB reachability tier)"}
|
|
108
|
+
|
|
109
|
+
return {"family": family, "available": True, "checked": False,
|
|
110
|
+
"reason": f"unhandled requirement {req!r}; reachability deferred to WT-KB tier", "confidence": "none"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def filter_reachable(families: list[str], seq: str, installed_att: bool = False) -> dict:
|
|
114
|
+
"""Split a candidate writer list into mechanistically reachable vs rejected at this site sequence."""
|
|
115
|
+
verdicts = {f: target_site_available(f, seq, installed_att=installed_att) for f in families}
|
|
116
|
+
reachable = [f for f, v in verdicts.items() if v["available"]]
|
|
117
|
+
rejected = [{"family": f, "reason": v["reason"]} for f, v in verdicts.items() if not v["available"]]
|
|
118
|
+
return {"reachable": reachable, "rejected": rejected, "verdicts": verdicts}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pen_stack.rag - see the PEN-STACK program doc."""
|
pen_stack/rag/corpus.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""RAG corpus builder.
|
|
2
|
+
|
|
3
|
+
Assembles the provenance-tagged corpus that grounds the chat's General lane. EVERY chunk is real, already-curated
|
|
4
|
+
repository content - DOI-backed verbatim quotes, the metric guide, the writer-atlas family cards, the data/model
|
|
5
|
+
cards, and the scope boundary - never fabricated paper text. Each chunk carries:
|
|
6
|
+
chunk_id, text, source_id, doi, access_grade, type, scope_status
|
|
7
|
+
The built table is `data/rag_corpus.parquet` (SHA-locked at build); its embeddings are `data/rag_corpus_emb.npy`.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from pen_stack._resources import project_root
|
|
16
|
+
|
|
17
|
+
_FIELDS = ["chunk_id", "text", "source_id", "doi", "access_grade", "type", "scope_status"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _writer_efficiency_chunks() -> list[dict]:
|
|
21
|
+
from pen_stack.atlas import writer_efficiency as we
|
|
22
|
+
out = []
|
|
23
|
+
for r in we.records().itertuples(index=False):
|
|
24
|
+
q = str(getattr(r, "quote", "") or "").strip()
|
|
25
|
+
if not q:
|
|
26
|
+
continue
|
|
27
|
+
eff = getattr(r, "efficiency_pct", None)
|
|
28
|
+
eff_s = f" Measured integration efficiency ~{eff}%." if eff is not None and not pd.isna(eff) else ""
|
|
29
|
+
out.append({
|
|
30
|
+
"text": (f"{r.system} ({r.family}) at {r.locus} in {r.cell_type}.{eff_s} "
|
|
31
|
+
f"Reported: \"{q}\""),
|
|
32
|
+
"source_id": f"writer_eff:{r.system}:{r.locus}", "doi": str(r.doi),
|
|
33
|
+
"access_grade": str(getattr(r, "source_access", "unknown")),
|
|
34
|
+
"type": "efficiency_measurement", "scope_status": "measured"})
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _metric_guide_chunks() -> list[dict]:
|
|
39
|
+
from pen_stack.web.guide import metric_guide
|
|
40
|
+
mg = metric_guide() or {}
|
|
41
|
+
out = []
|
|
42
|
+
for key, m in (mg.get("metrics") or {}).items():
|
|
43
|
+
text = (f"Metric '{m.get('label', key)}' ({key}): scale {m.get('scale')}, {m.get('direction')}. "
|
|
44
|
+
f"What it means: {m.get('means')} How it is computed: {m.get('computed')} "
|
|
45
|
+
f"Validation: {m.get('validation')} Reference: {m.get('reference')}")
|
|
46
|
+
cites = m.get("reference") or ""
|
|
47
|
+
out.append({"text": text, "source_id": f"metric_guide:{key}", "doi": str(cites),
|
|
48
|
+
"access_grade": "curated", "type": "metric_card", "scope_status": "documented"})
|
|
49
|
+
sd = mg.get("safety_decision")
|
|
50
|
+
if sd:
|
|
51
|
+
out.append({"text": "Guardian biosecurity decisions: " + "; ".join(f"{k} = {v}" for k, v in sd.items()),
|
|
52
|
+
"source_id": "metric_guide:safety_decision", "doi": "", "access_grade": "curated",
|
|
53
|
+
"type": "metric_card", "scope_status": "documented"})
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _atlas_card_chunks() -> list[dict]:
|
|
58
|
+
from pen_stack.rag.index import build_cards
|
|
59
|
+
out = []
|
|
60
|
+
for c in build_cards():
|
|
61
|
+
out.append({"text": c.text, "source_id": f"atlas:{c.key}", "doi": ";".join(c.citations),
|
|
62
|
+
"access_grade": "curated", "type": "writer_atlas_card", "scope_status": "measured/candidate"})
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _doc_card_chunks() -> list[dict]:
|
|
67
|
+
"""Chunk the committed data/model cards (docs/cards/*.md) by paragraph."""
|
|
68
|
+
out = []
|
|
69
|
+
cards_dir = project_root() / "docs" / "cards"
|
|
70
|
+
if not cards_dir.exists():
|
|
71
|
+
return out
|
|
72
|
+
for p in sorted(cards_dir.glob("*.md")):
|
|
73
|
+
text = p.read_text(encoding="utf-8", errors="ignore")
|
|
74
|
+
for para in [s.strip() for s in text.split("\n\n")]:
|
|
75
|
+
clean = " ".join(para.split())
|
|
76
|
+
if len(clean) < 80 or clean.startswith("#") and len(clean) < 120:
|
|
77
|
+
continue
|
|
78
|
+
out.append({"text": clean[:900], "source_id": f"card:{p.stem}", "doi": "",
|
|
79
|
+
"access_grade": "curated", "type": "data_card", "scope_status": "documented"})
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _scope_chunks() -> list[dict]:
|
|
84
|
+
"""The scope boundary: the known-unknowns the engine never predicts."""
|
|
85
|
+
from pen_stack.web.guide import pen_stack_facts
|
|
86
|
+
out = []
|
|
87
|
+
try:
|
|
88
|
+
facts = pen_stack_facts() or {}
|
|
89
|
+
except Exception: # noqa: BLE001
|
|
90
|
+
facts = {}
|
|
91
|
+
ku = facts.get("known_unknowns") or facts.get("scope", {}).get("known_unknowns")
|
|
92
|
+
if ku:
|
|
93
|
+
out.append({"text": ("PEN-STACK never predicts these known-unknowns (it abstains): "
|
|
94
|
+
+ "; ".join(str(x) for x in ku) + ". These are measured clinical endpoints "
|
|
95
|
+
"outside the engine's validated envelope."),
|
|
96
|
+
"source_id": "scope:known_unknowns", "doi": "", "access_grade": "curated",
|
|
97
|
+
"type": "scope_boundary", "scope_status": "known_unknown"})
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_corpus() -> pd.DataFrame:
|
|
102
|
+
"""Assemble the full provenance-tagged corpus from real repository content."""
|
|
103
|
+
rows: list[dict] = []
|
|
104
|
+
for fn in (_writer_efficiency_chunks, _metric_guide_chunks, _atlas_card_chunks,
|
|
105
|
+
_doc_card_chunks, _scope_chunks):
|
|
106
|
+
try:
|
|
107
|
+
rows.extend(fn())
|
|
108
|
+
except Exception as e: # noqa: BLE001 - a missing source must not silently corrupt the corpus; surface it
|
|
109
|
+
rows.append({"text": f"[source builder {fn.__name__} failed: {e}]", "source_id": "build_error",
|
|
110
|
+
"doi": "", "access_grade": "n/a", "type": "build_error", "scope_status": "n/a"})
|
|
111
|
+
# drop build-error rows from the shippable corpus but keep the count accurate
|
|
112
|
+
df = pd.DataFrame([r for r in rows if r["type"] != "build_error"])
|
|
113
|
+
df = df.drop_duplicates(subset=["text"]).reset_index(drop=True)
|
|
114
|
+
df["chunk_id"] = [f"c{i:04d}" for i in range(len(df))]
|
|
115
|
+
errors = [r for r in rows if r["type"] == "build_error"]
|
|
116
|
+
if errors:
|
|
117
|
+
raise RuntimeError("corpus build had source errors: " + " | ".join(r["text"] for r in errors))
|
|
118
|
+
return df[_FIELDS]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def corpus_path() -> Path:
|
|
122
|
+
return project_root() / "data" / "rag_corpus.parquet"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def emb_path() -> Path:
|
|
126
|
+
return project_root() / "data" / "rag_corpus_emb.npy"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_corpus() -> pd.DataFrame:
|
|
130
|
+
p = corpus_path()
|
|
131
|
+
if not p.exists():
|
|
132
|
+
raise FileNotFoundError(f"{p} not built; run scripts/build_rag_corpus.py")
|
|
133
|
+
return pd.read_parquet(p)
|
pen_stack/rag/embed.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""RAG embedding client.
|
|
2
|
+
|
|
3
|
+
A pinned local embedder (`nomic-embed-text` via the already-running Ollama) for semantic retrieval, with a
|
|
4
|
+
deterministic, model-free **lexical fallback** so the General lane still retrieves (degraded) when no
|
|
5
|
+
embedder is reachable - e.g. in CI or if Ollama is down. The CORPUS embeddings are computed once at build time and
|
|
6
|
+
committed (`data/rag_corpus_emb.npy`); only the live QUERY is embedded at runtime, so retrieval stays reproducible.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import urllib.request
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
EMBED_MODEL = os.getenv("PEN_RAG_EMBED_MODEL", "nomic-embed-text")
|
|
18
|
+
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
|
19
|
+
_TIMEOUT = int(os.getenv("PEN_RAG_EMBED_TIMEOUT", "30"))
|
|
20
|
+
# nomic-embed-text is trained with task prefixes; using them is REQUIRED for retrieval to separate relevant from
|
|
21
|
+
# irrelevant (without them every pair sits in a narrow high-cosine band and abstention cannot discriminate).
|
|
22
|
+
_PREFIX = {"query": "search_query: ", "document": "search_document: "}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def embed_text(text: str, task: str = "query") -> np.ndarray | None:
|
|
26
|
+
"""L2-normalised embedding of one string via Ollama (with the nomic task prefix), or None if unreachable."""
|
|
27
|
+
if os.getenv("PEN_RAG_NO_EMBED") == "1":
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
req = urllib.request.Request(
|
|
31
|
+
f"{OLLAMA_HOST}/api/embeddings",
|
|
32
|
+
data=json.dumps({"model": EMBED_MODEL, "prompt": _PREFIX.get(task, "") + (text or "")}).encode(),
|
|
33
|
+
headers={"Content-Type": "application/json"})
|
|
34
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
|
|
35
|
+
vec = json.loads(r.read().decode())["embedding"]
|
|
36
|
+
v = np.asarray(vec, dtype="float32")
|
|
37
|
+
n = float(np.linalg.norm(v))
|
|
38
|
+
return v / n if n else v
|
|
39
|
+
except Exception: # noqa: BLE001 - any failure -> caller uses the lexical fallback
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# latency fix: cache live query embeddings in-process. The chat embeds the user's message on every General-lane
|
|
44
|
+
# request; repeat phrasings ("hi", "what is DNA", "what is gene editing") would otherwise hit Ollama every time. The
|
|
45
|
+
# document side is unaffected - corpus embeddings are computed once at build time and committed.
|
|
46
|
+
@lru_cache(maxsize=256)
|
|
47
|
+
def _embed_query_cached(text: str) -> tuple | None:
|
|
48
|
+
v = embed_text(text, task="query")
|
|
49
|
+
return tuple(float(x) for x in v) if v is not None else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def embed_query(text: str) -> np.ndarray | None:
|
|
53
|
+
"""Cached query-side embedding. Identical to `embed_text(text, task='query')` but memoised by text."""
|
|
54
|
+
t = (text or "").strip()
|
|
55
|
+
if not t:
|
|
56
|
+
return embed_text(t, task="query")
|
|
57
|
+
cached = _embed_query_cached(t)
|
|
58
|
+
return np.asarray(cached, dtype="float32") if cached is not None else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def embed_corpus(texts: list[str]) -> np.ndarray:
|
|
62
|
+
"""Embed the corpus DOCUMENTS at BUILD time. Raises if the embedder is unreachable (the build must be
|
|
63
|
+
deterministic and must never silently fall back to a different representation)."""
|
|
64
|
+
out = []
|
|
65
|
+
for t in texts:
|
|
66
|
+
v = embed_text(t, task="document")
|
|
67
|
+
if v is None:
|
|
68
|
+
raise RuntimeError(f"embedder '{EMBED_MODEL}' unreachable at {OLLAMA_HOST}; cannot build corpus embeddings")
|
|
69
|
+
out.append(v)
|
|
70
|
+
return np.vstack(out).astype("float32")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- deterministic, model-free lexical fallback (content-token Jaccard) --------------------------------------
|
|
74
|
+
# Stopwords are dropped so the fallback discriminates on CONTENT words: an out-of-corpus query (e.g. "what is the
|
|
75
|
+
# capital of France") must not match a genome-writing chunk via shared function words and slip past the abstention.
|
|
76
|
+
_STOP = frozenset((
|
|
77
|
+
"the and for are was were that this with from have has had not but you your his her its our their they them "
|
|
78
|
+
"what which who whom whose when where why how does did doing done can could should would will shall may might "
|
|
79
|
+
"into onto over under above below than then them they about after before between because while during each "
|
|
80
|
+
"any all some more most much many few any both either neither nor yet via per such only also same other "
|
|
81
|
+
"one two three get got make made use used using like just very too here there out off own").split())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def tokenize(s: str) -> set[str]:
|
|
85
|
+
return {w for w in "".join(c.lower() if c.isalnum() else " " for c in (s or "")).split()
|
|
86
|
+
if len(w) > 2 and w not in _STOP}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def lexical_scores(query: str, texts: list[str]) -> np.ndarray:
|
|
90
|
+
"""Jaccard overlap of the query tokens with each document's tokens. Deterministic; no model."""
|
|
91
|
+
q = tokenize(query)
|
|
92
|
+
if not q:
|
|
93
|
+
return np.zeros(len(texts), dtype="float32")
|
|
94
|
+
out = np.zeros(len(texts), dtype="float32")
|
|
95
|
+
for i, t in enumerate(texts):
|
|
96
|
+
d = tokenize(t)
|
|
97
|
+
out[i] = len(q & d) / len(q | d) if d else 0.0
|
|
98
|
+
return out
|
pen_stack/rag/ground.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""RAG grounding for the chat's General lane.
|
|
2
|
+
|
|
3
|
+
Retrieval is ADDITIVE, not a gate. The General lane ANSWERS general and social questions by default (restoring the
|
|
4
|
+
hybrid answering behaviour), clearly LABELLED as general knowledge - a labelled general answer is not a
|
|
5
|
+
fabrication, because it is never presented as a PEN-STACK result. Corpus retrieval, when it finds a relevant chunk,
|
|
6
|
+
UPGRADES the answer to 'literature-cited' (with its sources). Abstention is the rare exception: it fires only for a
|
|
7
|
+
SPECIFIC, unsourceable empirical / quantitative genome-writing claim the corpus does not cover and the engine is
|
|
8
|
+
not computing - the genuine citation-or-silence case - so the model cannot dress a fabricated statistic as fact.
|
|
9
|
+
|
|
10
|
+
Branches:
|
|
11
|
+
social -> a friendly answer + a pointer to what the engine can compute (no retrieval, no abstention).
|
|
12
|
+
cited -> a relevant corpus chunk was retrieved -> cite-or-silence answer, provenance 'literature-cited'.
|
|
13
|
+
general -> background / textbook knowledge -> answer from the LLM, labelled (provenance 'general').
|
|
14
|
+
abstained -> a specific unsourceable empirical claim -> decline + redirect to the engine (provenance 'abstained').
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from pen_stack.rag.embed import tokenize
|
|
22
|
+
from pen_stack.rag.retrieve import retrieve
|
|
23
|
+
|
|
24
|
+
# nomic-embed-text compresses cosines into a narrow high band, so an absolute cosine alone cannot separate a valid
|
|
25
|
+
# in-corpus query from an off-topic one. The hybrid gate (cosine floor + lexical content-overlap, with a high-cosine
|
|
26
|
+
# escape) decides only whether the corpus is a relevant SOURCE to cite - NOT whether to answer at all.
|
|
27
|
+
THRESHOLD = float(os.getenv("PEN_RAG_THRESHOLD", "0.50"))
|
|
28
|
+
_SEM_HIGH = float(os.getenv("PEN_RAG_THRESHOLD_HIGH", "0.72"))
|
|
29
|
+
_LEX_THRESHOLD = float(os.getenv("PEN_RAG_LEX_THRESHOLD", "0.05"))
|
|
30
|
+
|
|
31
|
+
# A social / conversational opener: answered naturally, never gated on the corpus.
|
|
32
|
+
_SOCIAL = re.compile(r"^\s*(hi|hii+|hey+|hello+|yo|hola|sup|greetings|good (morning|afternoon|evening|day)|"
|
|
33
|
+
r"thanks?|thank you|cheers|ok(ay)?|cool|nice|great|awesome|who are you|what are you|"
|
|
34
|
+
r"how are you|what'?s up|introduce yourself|help)\b", re.I)
|
|
35
|
+
|
|
36
|
+
# A SPECIFIC genome-writing QUANTITATIVE / empirical claim (not definitional / mechanistic background). When the
|
|
37
|
+
# corpus has no source for it and it is in the general lane (so the engine is not computing it), we abstain +
|
|
38
|
+
# redirect rather than let the model invent a statistic dressed up as fact.
|
|
39
|
+
_SPECIFIC_EMPIRICAL = re.compile(
|
|
40
|
+
r"\b(efficienc\w*|integration rate|specificit\w*|on-?target rate|off-?target rate|knock-?in rate|titer|titre|"
|
|
41
|
+
r"affinit\w*|\bkd\b|ic50|fold[- ]improvement|expression level|copy number|"
|
|
42
|
+
r"percent\w* (of )?(integration|editing|knock|insertion))\b", re.I)
|
|
43
|
+
|
|
44
|
+
_SOCIAL_REPLY = ("Hi - I'm PEN-STACK's genome-writing co-scientist. Ask a genome-writing question (where to insert, "
|
|
45
|
+
"which writer, delivery, immune risk, off-targets, safety) and the engine computes a grounded, "
|
|
46
|
+
"traceable answer. I can also answer general background questions, clearly labelled as such.")
|
|
47
|
+
_GENERAL_NO_LLM = ("I can answer general background questions when the language model is enabled. Meanwhile, "
|
|
48
|
+
"PEN-STACK's engine can compute grounded, specific answers for your case.")
|
|
49
|
+
_LABEL = "Literature-cited (corpus) - not a PEN-STACK-computed result:"
|
|
50
|
+
_ABSTAIN_SPECIFIC = ("I don't have a grounded source for that specific value and I won't invent one. PEN-STACK's "
|
|
51
|
+
"engine can compute it from a concrete design - give me the gene, cell type, and cargo (or "
|
|
52
|
+
"the writer/guide) and I'll run the site, writer-efficiency, or off-target tools.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_grounded(query: str, hits: list[dict], method: str) -> bool:
|
|
56
|
+
"""Is the corpus a relevant SOURCE to cite for this query?"""
|
|
57
|
+
if not hits:
|
|
58
|
+
return False
|
|
59
|
+
top = hits[0]
|
|
60
|
+
if not method.startswith("semantic"):
|
|
61
|
+
return top["score"] >= _LEX_THRESHOLD
|
|
62
|
+
lexical_overlap = bool(tokenize(query) & tokenize(top["text"]))
|
|
63
|
+
return top["score"] >= THRESHOLD and (lexical_overlap or top["score"] >= _SEM_HIGH)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _dedup_sources(hits: list[dict]) -> list[dict]:
|
|
67
|
+
seen, out = set(), []
|
|
68
|
+
for h in hits:
|
|
69
|
+
sid = h["source_id"]
|
|
70
|
+
if sid in seen:
|
|
71
|
+
continue
|
|
72
|
+
seen.add(sid)
|
|
73
|
+
out.append({"source_id": sid, "doi": h.get("doi") or None, "type": h["type"], "score": round(h["score"], 3)})
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cited_answer(message: str, r: dict, allow_llm: bool) -> dict:
|
|
78
|
+
floor = THRESHOLD if r["method"].startswith("semantic") else _LEX_THRESHOLD
|
|
79
|
+
grounded_hits = [h for h in r["hits"] if h["score"] >= floor] or r["hits"][:2]
|
|
80
|
+
sources = _dedup_sources(grounded_hits)
|
|
81
|
+
deterministic = _LABEL + "\n\n" + "\n\n".join(
|
|
82
|
+
f"- {h['text']} [{h['source_id']}{(' · ' + h['doi']) if h.get('doi') else ''}]" for h in grounded_hits)
|
|
83
|
+
base = {"status": "grounded", "sources": sources, "provenance": "literature-cited", "grounded": True,
|
|
84
|
+
"retrieval": r["method"], "top_score": r["top_score"]}
|
|
85
|
+
if allow_llm:
|
|
86
|
+
from pen_stack.web.llm import _enforce_grounding, _run_llm
|
|
87
|
+
from pen_stack.web.tools import extract_grounded_numbers
|
|
88
|
+
ctx = "\n\n".join(f"[{i + 1}] ({h['source_id']}{(' DOI ' + h['doi']) if h.get('doi') else ''}) {h['text']}"
|
|
89
|
+
for i, h in enumerate(grounded_hits))
|
|
90
|
+
system = ("You answer ONLY from the SOURCES provided. After each claim, cite the bracketed [n] it came from. "
|
|
91
|
+
"If the sources do not cover the question, say so. Never add a number, name, or vehicle that is "
|
|
92
|
+
"not in the sources. Never present anything as a PEN-STACK-computed result.")
|
|
93
|
+
text, backend = _run_llm(f"SOURCES:\n{ctx}\n\nQUESTION: {message}\n\nAnswer concisely, citing [n].", system,
|
|
94
|
+
kind="general") # fast-path: short timeout + token cap for cited answers
|
|
95
|
+
if text:
|
|
96
|
+
allow = extract_grounded_numbers({"hits": [h["text"] for h in grounded_hits]})
|
|
97
|
+
return {**base, "reply": _LABEL + "\n\n" + _enforce_grounding(text.strip(), allow), "backend": backend}
|
|
98
|
+
return {**base, "reply": deterministic, "backend": "deterministic"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ground_general(message: str, *, allow_llm: bool = True) -> dict:
|
|
102
|
+
"""Return the chat envelope for the General lane: answer (social / general / cited) by default; abstain only on a
|
|
103
|
+
specific unsourceable empirical claim."""
|
|
104
|
+
msg = (message or "").strip()
|
|
105
|
+
if _SOCIAL.match(msg):
|
|
106
|
+
return {"status": "social", "reply": _SOCIAL_REPLY, "sources": [], "provenance": "general",
|
|
107
|
+
"grounded": False, "backend": "deterministic", "retrieval": "none", "top_score": 0.0}
|
|
108
|
+
|
|
109
|
+
r = retrieve(msg, k=4)
|
|
110
|
+
if _is_grounded(msg, r["hits"], r["method"]):
|
|
111
|
+
return _cited_answer(msg, r, allow_llm)
|
|
112
|
+
|
|
113
|
+
# no corpus source. A specific empirical genome-writing claim -> abstain + redirect to the engine.
|
|
114
|
+
if _SPECIFIC_EMPIRICAL.search(msg):
|
|
115
|
+
return {"status": "abstained", "reply": _ABSTAIN_SPECIFIC, "sources": [], "provenance": "abstained",
|
|
116
|
+
"grounded": False, "backend": "deterministic", "retrieval": r["method"], "top_score": r["top_score"]}
|
|
117
|
+
|
|
118
|
+
# general / background knowledge -> ANSWER, clearly labelled as general (never a PEN-STACK result).
|
|
119
|
+
if allow_llm:
|
|
120
|
+
from pen_stack.web.llm import SYSTEM_GENERAL, _GENERAL_LABEL, _run_llm
|
|
121
|
+
text, backend = _run_llm(
|
|
122
|
+
f"USER: {msg}\n\nAnswer from general knowledge, clearly and at a graduate level. Do not present anything "
|
|
123
|
+
f"as a PEN-STACK result.", SYSTEM_GENERAL, kind="general") # fast-path timeout
|
|
124
|
+
if text:
|
|
125
|
+
return {"status": "general", "reply": _GENERAL_LABEL + "\n\n" + text.strip(), "sources": [],
|
|
126
|
+
"provenance": "general", "grounded": False, "backend": backend, "retrieval": r["method"],
|
|
127
|
+
"top_score": r["top_score"]}
|
|
128
|
+
from pen_stack.web.llm import _GENERAL_LABEL
|
|
129
|
+
return {"status": "general", "reply": _GENERAL_LABEL + "\n\n" + _GENERAL_NO_LLM, "sources": [],
|
|
130
|
+
"provenance": "general", "grounded": False, "backend": "deterministic", "retrieval": r["method"],
|
|
131
|
+
"top_score": r["top_score"]}
|
pen_stack/rag/index.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Grounded document index for the PEN-STACK RAG.
|
|
2
|
+
|
|
3
|
+
Builds a cited corpus of fact cards from the curated atlas + WT-KB (each card carries its source DOIs),
|
|
4
|
+
so retrieval-grounded answers always have a citation. If PaperQA + an LLM are available they can index a
|
|
5
|
+
literature corpus on top; the keyword retriever here is the dependency-light default that guarantees the
|
|
6
|
+
"every factual claim is cited" contract without any model.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
_ATLAS = Path(__file__).resolve().parents[1] / "atlas" / "atlas.parquet"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Card:
|
|
20
|
+
key: str
|
|
21
|
+
text: str
|
|
22
|
+
citations: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_cards(atlas_parquet: str | Path = _ATLAS) -> list[Card]:
|
|
26
|
+
"""One fact card per writer family, summarising its measured targeting + readiness, with DOIs."""
|
|
27
|
+
df = pd.read_parquet(atlas_parquet)
|
|
28
|
+
cards: list[Card] = []
|
|
29
|
+
for fam, sub in df.groupby("family"):
|
|
30
|
+
core = sub[sub["entry_kind"].isin(["curated_core", "curated_rep"])]
|
|
31
|
+
rep = core.iloc[0] if len(core) else sub.iloc[0]
|
|
32
|
+
dois: list[str] = []
|
|
33
|
+
for d in core["key_dois"] if len(core) else sub["key_dois"]:
|
|
34
|
+
dois.extend(str(x) for x in list(d) if str(x).strip())
|
|
35
|
+
text = (f"Writer family {fam}: representative {rep['representative_system']}; "
|
|
36
|
+
f"mechanism {rep.get('mechanism_bucket')}; targeting {rep.get('targeting_modality')}; "
|
|
37
|
+
f"reachability {rep.get('reachability_tier')}; deliverability {rep.get('deliv_class')}; "
|
|
38
|
+
f"cargo {rep.get('cargo_capacity_bp')} bp; human-cell activity: "
|
|
39
|
+
f"{rep.get('human_cell_activity')}. {len(sub):,} systems catalogued.")
|
|
40
|
+
cards.append(Card(key=fam, text=text, citations=sorted(set(dois))))
|
|
41
|
+
return cards
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def retrieve(question: str, cards: list[Card], k: int = 3) -> list[Card]:
|
|
45
|
+
"""Keyword overlap retriever (lower-cased token Jaccard). Deterministic, no model needed."""
|
|
46
|
+
q = set(_tok(question))
|
|
47
|
+
scored = [(len(q & set(_tok(c.text + " " + c.key))), c) for c in cards]
|
|
48
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
49
|
+
return [c for n, c in scored if n > 0][:k]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _tok(s: str) -> list[str]:
|
|
53
|
+
return [w for w in "".join(ch.lower() if ch.isalnum() else " " for ch in s).split() if len(w) > 2]
|