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
pen_stack/rag/llm.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Provider-agnostic LLM layer for PEN-STACK services (RAG, agent, monitor).
|
|
2
|
+
|
|
3
|
+
Hybrid backend: a strong hosted model for reasoning/agent/Q&A (default NVIDIA Nemotron, OpenAI-compatible)
|
|
4
|
+
with automatic fallback to a local, free, private model (Ollama). The single switch is `configs/llm.yaml`.
|
|
5
|
+
|
|
6
|
+
This is strictly an orchestration/phrasing layer. Every quantitative claim and every citation still comes
|
|
7
|
+
from the deterministic validated-tool path; the LLM never introduces a number, gene, or citation. The
|
|
8
|
+
choice of model therefore does not affect scientific reproducibility - only the quality of orchestration
|
|
9
|
+
and prose. If no provider is reachable, the callers fall back to the deterministic answer (LLM optional).
|
|
10
|
+
|
|
11
|
+
Secrets: the API key is read from the env var named in `api_key_env`, then from the gitignored
|
|
12
|
+
`api_key_file`. Keys are NEVER committed.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
_CFG = Path(__file__).resolve().parents[2] / "configs" / "llm.yaml"
|
|
24
|
+
_ROOT = Path(__file__).resolve().parents[2]
|
|
25
|
+
|
|
26
|
+
_SYSTEM = ("You rephrase already-verified genome-writing facts into one clear paragraph for a wet-lab "
|
|
27
|
+
"scientist. Use ONLY the facts provided. Do NOT invent or alter any number, gene, or citation. "
|
|
28
|
+
"Do not give clinical advice. Keep it under 90 words.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_llm_config(path: str | Path = _CFG) -> dict:
|
|
32
|
+
return yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _provider_cfg(cfg: dict, name: str) -> dict | None:
|
|
36
|
+
return (cfg.get("providers") or {}).get(name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_key(pcfg: dict) -> str | None:
|
|
40
|
+
env = pcfg.get("api_key_env")
|
|
41
|
+
if env and os.environ.get(env):
|
|
42
|
+
return os.environ[env].strip()
|
|
43
|
+
f = pcfg.get("api_key_file")
|
|
44
|
+
if f:
|
|
45
|
+
p = Path(f)
|
|
46
|
+
if not p.is_absolute():
|
|
47
|
+
p = _ROOT / f
|
|
48
|
+
if p.exists():
|
|
49
|
+
return p.read_text(encoding="utf-8").strip()
|
|
50
|
+
return pcfg.get("api_key")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _norm_tool_calls(raw: list | None) -> list:
|
|
54
|
+
out = []
|
|
55
|
+
for c in raw or []:
|
|
56
|
+
fn = c.get("function", {})
|
|
57
|
+
args = fn.get("arguments", {})
|
|
58
|
+
if isinstance(args, str):
|
|
59
|
+
try:
|
|
60
|
+
args = json.loads(args or "{}")
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
args = {}
|
|
63
|
+
out.append({"function": {"name": fn.get("name"), "arguments": args}})
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _chat_openai(pcfg: dict, messages: list, tools: list | None, temperature: float,
|
|
68
|
+
timeout: int) -> dict | None:
|
|
69
|
+
"""OpenAI-compatible /v1/chat/completions (NVIDIA NIM, OpenAI, vLLM, Ollama /v1)."""
|
|
70
|
+
base = pcfg["api_base"].rstrip("/")
|
|
71
|
+
key = _resolve_key(pcfg)
|
|
72
|
+
payload = {"model": pcfg["model"], "messages": messages, "temperature": temperature,
|
|
73
|
+
"max_tokens": int(pcfg.get("max_tokens", 1024))}
|
|
74
|
+
if tools:
|
|
75
|
+
payload["tools"] = tools
|
|
76
|
+
payload["tool_choice"] = "auto"
|
|
77
|
+
headers = {"Content-Type": "application/json"}
|
|
78
|
+
if key:
|
|
79
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
80
|
+
req = urllib.request.Request(f"{base}/chat/completions", data=json.dumps(payload).encode(), headers=headers)
|
|
81
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
82
|
+
d = json.load(r)
|
|
83
|
+
msg = d["choices"][0]["message"]
|
|
84
|
+
return {"content": (msg.get("content") or "").strip(), "tool_calls": _norm_tool_calls(msg.get("tool_calls")),
|
|
85
|
+
"raw": msg, "style": "openai"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _chat_ollama(pcfg: dict, messages: list, tools: list | None, temperature: float,
|
|
89
|
+
timeout: int) -> dict | None:
|
|
90
|
+
"""Ollama native /api/chat."""
|
|
91
|
+
base = pcfg["api_base"].rstrip("/")
|
|
92
|
+
payload = {"model": str(pcfg["model"]).split("/")[-1], "messages": messages, "stream": False,
|
|
93
|
+
"options": {"temperature": temperature}}
|
|
94
|
+
if tools:
|
|
95
|
+
payload["tools"] = tools
|
|
96
|
+
req = urllib.request.Request(f"{base}/api/chat", data=json.dumps(payload).encode(),
|
|
97
|
+
headers={"Content-Type": "application/json"})
|
|
98
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
99
|
+
d = json.load(r)
|
|
100
|
+
msg = d.get("message", {})
|
|
101
|
+
return {"content": (msg.get("content") or "").strip(), "tool_calls": _norm_tool_calls(msg.get("tool_calls")),
|
|
102
|
+
"raw": msg, "style": "ollama"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _chat_anthropic(pcfg: dict, messages: list, tools: list | None, temperature: float,
|
|
106
|
+
timeout: int) -> dict | None:
|
|
107
|
+
"""Anthropic Messages API (/v1/messages). System prompts are a top-level field (not a message role),
|
|
108
|
+
and tool calls come back as `tool_use` content blocks; both are normalised to the common shape."""
|
|
109
|
+
base = pcfg.get("api_base", "https://api.anthropic.com/v1").rstrip("/")
|
|
110
|
+
key = _resolve_key(pcfg)
|
|
111
|
+
sys_txt = "\n".join(m["content"] for m in messages if m.get("role") == "system")
|
|
112
|
+
msgs = [{"role": m["role"], "content": m["content"]} for m in messages if m.get("role") != "system"]
|
|
113
|
+
payload = {"model": pcfg["model"], "max_tokens": int(pcfg.get("max_tokens", 1024)),
|
|
114
|
+
"temperature": temperature, "messages": msgs}
|
|
115
|
+
if sys_txt:
|
|
116
|
+
payload["system"] = sys_txt
|
|
117
|
+
if tools: # convert OpenAI-style function tools to Anthropic tool schema
|
|
118
|
+
payload["tools"] = [{"name": t["function"]["name"], "description": t["function"].get("description", ""),
|
|
119
|
+
"input_schema": t["function"].get("parameters", {"type": "object", "properties": {}})}
|
|
120
|
+
for t in tools]
|
|
121
|
+
headers = {"content-type": "application/json", "anthropic-version": "2023-06-01"}
|
|
122
|
+
if key:
|
|
123
|
+
headers["x-api-key"] = key
|
|
124
|
+
req = urllib.request.Request(f"{base}/messages", data=json.dumps(payload).encode(), headers=headers)
|
|
125
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
126
|
+
d = json.load(r)
|
|
127
|
+
content, tool_calls = "", []
|
|
128
|
+
for block in d.get("content", []):
|
|
129
|
+
if block.get("type") == "text":
|
|
130
|
+
content += block.get("text", "")
|
|
131
|
+
elif block.get("type") == "tool_use": # keep the tool_use id so the caller can build a tool_result
|
|
132
|
+
tool_calls.append({"id": block.get("id"),
|
|
133
|
+
"function": {"name": block.get("name"), "arguments": block.get("input", {})}})
|
|
134
|
+
# `raw` must be a valid Anthropic assistant message (role + content blocks) so a multi-turn tool loop can
|
|
135
|
+
# append it verbatim; the full response object would be rejected on the next call.
|
|
136
|
+
raw = {"role": "assistant", "content": d.get("content", [])}
|
|
137
|
+
return {"content": content.strip(), "tool_calls": tool_calls, "raw": raw, "style": "anthropic"}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _call_provider(name: str, cfg: dict, messages: list, tools: list | None, timeout: int) -> dict | None:
|
|
141
|
+
pcfg = _provider_cfg(cfg, name)
|
|
142
|
+
if not pcfg:
|
|
143
|
+
return None
|
|
144
|
+
temp = float(cfg.get("temperature", 0.1))
|
|
145
|
+
style = pcfg.get("style", "openai")
|
|
146
|
+
try:
|
|
147
|
+
if style == "ollama":
|
|
148
|
+
return _chat_ollama(pcfg, messages, tools, temp, timeout)
|
|
149
|
+
if style == "anthropic":
|
|
150
|
+
return _chat_anthropic(pcfg, messages, tools, temp, timeout)
|
|
151
|
+
return _chat_openai(pcfg, messages, tools, temp, timeout)
|
|
152
|
+
except Exception: # noqa: BLE001 - any provider failure -> let the caller try the fallback
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Cooldown cache: once a provider fails (e.g. Ollama not installed on the laptop tier), skip it for
|
|
157
|
+
# `health_ttl` seconds instead of re-attempting it on every call. This is what prevents the multi-minute
|
|
158
|
+
# stalls when a configured provider is absent/slow - we pay one failed attempt, then bypass it.
|
|
159
|
+
_COOLDOWN: dict[str, float] = {}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def chat(messages: list, tools: list | None = None, cfg: dict | None = None,
|
|
163
|
+
timeout: int | None = None) -> dict | None:
|
|
164
|
+
"""Provider-agnostic chat. Tries the active provider, then the configured fallback, skipping any
|
|
165
|
+
provider in cooldown (recently unreachable). Returns {content, tool_calls, provider} or None if every
|
|
166
|
+
provider fails (callers then degrade deterministically - the LLM is non-load-bearing)."""
|
|
167
|
+
import time
|
|
168
|
+
cfg = cfg or load_llm_config()
|
|
169
|
+
timeout = timeout if timeout is not None else int(cfg.get("call_timeout", 60))
|
|
170
|
+
ttl = float(cfg.get("health_ttl", 120))
|
|
171
|
+
order = [cfg.get("provider", "nvidia")]
|
|
172
|
+
fb = cfg.get("fallback")
|
|
173
|
+
if fb and fb not in order:
|
|
174
|
+
order.append(fb)
|
|
175
|
+
now = time.time()
|
|
176
|
+
tried_any = False
|
|
177
|
+
for name in order:
|
|
178
|
+
if _COOLDOWN.get(name, 0) > now: # provider recently failed -> skip without waiting
|
|
179
|
+
continue
|
|
180
|
+
tried_any = True
|
|
181
|
+
res = _call_provider(name, cfg, messages, tools, timeout)
|
|
182
|
+
if res is not None:
|
|
183
|
+
res["provider"] = name
|
|
184
|
+
_COOLDOWN.pop(name, None)
|
|
185
|
+
return res
|
|
186
|
+
_COOLDOWN[name] = now + ttl # mark unreachable; don't retry for ttl seconds
|
|
187
|
+
if not tried_any: # every provider in cooldown -> one cheap retry of the first
|
|
188
|
+
name = order[0]
|
|
189
|
+
res = _call_provider(name, cfg, messages, tools, min(timeout, int(cfg.get("health_timeout", 8))))
|
|
190
|
+
if res is not None:
|
|
191
|
+
res["provider"] = name
|
|
192
|
+
_COOLDOWN.pop(name, None)
|
|
193
|
+
return res
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def active_provider(cfg: dict | None = None, timeout: int | None = None) -> str | None:
|
|
198
|
+
"""Name of the first reachable provider (active, then fallback), or None. Uses the config `health_timeout`
|
|
199
|
+
by default so an absent provider is detected quickly (and then cooled down by chat())."""
|
|
200
|
+
cfg = cfg or load_llm_config()
|
|
201
|
+
timeout = timeout if timeout is not None else int(cfg.get("health_timeout", 8))
|
|
202
|
+
r = chat([{"role": "user", "content": "ok"}], cfg=cfg, timeout=timeout)
|
|
203
|
+
return r.get("provider") if r else None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def available(cfg: dict | None = None, timeout: int = 30) -> bool:
|
|
207
|
+
return active_provider(cfg, timeout) is not None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def phrase(facts: str, cfg: dict | None = None, timeout: int = 120) -> str | None:
|
|
211
|
+
"""Rephrase grounded facts. Returns None on any failure (caller keeps the deterministic answer)."""
|
|
212
|
+
msgs = [{"role": "system", "content": _SYSTEM},
|
|
213
|
+
{"role": "user", "content": f"Facts:\n{facts}\n\nRephrase as one paragraph."}]
|
|
214
|
+
r = chat(msgs, cfg=cfg, timeout=timeout)
|
|
215
|
+
return (r.get("content") or None) if r else None
|
pen_stack/rag/qa.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Grounded, cited Q&A over the PEN-STACK platform.
|
|
2
|
+
|
|
3
|
+
The front door for non-expert users. Contract (enforced by pen_stack.agent.guardrails):
|
|
4
|
+
* clinical-directive questions are refused;
|
|
5
|
+
* every *quantitative* claim is produced by a validated tool call (writability / atlas / cross-link),
|
|
6
|
+
never guessed by the LLM - the answer's ``provenance`` block names the tool;
|
|
7
|
+
* every factual claim carries a citation (DOIs from the curated atlas/WT-KB).
|
|
8
|
+
|
|
9
|
+
An optional LLM (Ollama/Qwen via litellm) only *phrases* the grounded facts; it is never the source of a
|
|
10
|
+
number or a citation. With no LLM available the deterministic tool+retrieval path still satisfies the
|
|
11
|
+
contract - that is the whole point.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from pen_stack.agent.guardrails import DISCLAIMER, enforce_grounded, out_of_scope
|
|
18
|
+
from pen_stack.rag.index import build_cards, retrieve
|
|
19
|
+
|
|
20
|
+
_GENE_RE = re.compile(r"\b([A-Z][A-Z0-9]{2,9})\b") # crude gene-symbol cue (TRAC, CCR5, ...)
|
|
21
|
+
_FAMILY_HINTS = {
|
|
22
|
+
"bridge": "bridge_IS110", "is110": "bridge_IS110", "iscro4": "bridge_IS110",
|
|
23
|
+
"seek": "seek_IS1111", "is1111": "seek_IS1111", "cast": "CAST_VK",
|
|
24
|
+
"integrase": "serine_integrase", "bxb1": "serine_integrase", "paste": "PE_integrase",
|
|
25
|
+
"prime": "PE_integrase", "cas9": "Cas9", "cas12a": "Cas12a", "tnpb": "TnpB_Fanzor",
|
|
26
|
+
"fanzor": "TnpB_Fanzor",
|
|
27
|
+
}
|
|
28
|
+
_WRITABLE_CUES = ("where", "writable", "safe harbour", "safe harbor", "insert", "insertion site", "locus")
|
|
29
|
+
# Standing citation for tool-derived writability claims: the Writable Genome atlas
|
|
30
|
+
# (TRIP durability supervision + clinical-CIS safety supervision).
|
|
31
|
+
_WRITABILITY_CITATIONS = ["10.1016/j.cell.2013.07.018"] # Akhtar 2013 (TRIP) - durability supervision
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _family_in(question: str) -> str | None:
|
|
35
|
+
q = question.lower()
|
|
36
|
+
for cue, fam in _FAMILY_HINTS.items():
|
|
37
|
+
if cue in q:
|
|
38
|
+
return fam
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def answer(question: str, ct: str = "k562", use_llm: bool = False) -> dict:
|
|
43
|
+
refusal = out_of_scope(question)
|
|
44
|
+
if refusal:
|
|
45
|
+
return {"refused": True, "answer": refusal, "citations": [], "provenance": [],
|
|
46
|
+
"disclaimer": DISCLAIMER}
|
|
47
|
+
|
|
48
|
+
cards = build_cards()
|
|
49
|
+
retrieved = retrieve(question, cards, k=3)
|
|
50
|
+
citations = sorted({d for c in retrieved for d in c.citations})
|
|
51
|
+
provenance: list[dict] = []
|
|
52
|
+
parts: list[str] = []
|
|
53
|
+
|
|
54
|
+
# --- numeric route 1: "where can I write / writable loci for GENE" -> writability tool ---
|
|
55
|
+
if any(cue in question.lower() for cue in _WRITABLE_CUES):
|
|
56
|
+
genes = [g for g in _GENE_RE.findall(question) if g not in {"PEN", "STACK", "DNA", "RNA"}]
|
|
57
|
+
if genes:
|
|
58
|
+
try:
|
|
59
|
+
from pen_stack.atlas.crosslink import loci_for_gene
|
|
60
|
+
g = loci_for_gene(genes[0], ct)
|
|
61
|
+
if not g.empty:
|
|
62
|
+
w = float(g["writability"].max())
|
|
63
|
+
provenance.append({"tool": "crosslink.loci_for_gene",
|
|
64
|
+
"args": {"gene": genes[0], "ct": ct},
|
|
65
|
+
"result": {"max_writability": round(w, 3), "n_bins": int(len(g))}})
|
|
66
|
+
parts.append(f"For {genes[0]} in {ct}, the most writable bin scores "
|
|
67
|
+
f"{w:.3f} (writability = safety x durability), across {len(g)} bins.")
|
|
68
|
+
citations = sorted(set(citations) | set(_WRITABILITY_CITATIONS))
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
parts.append("(Writable-genome atlas not loaded; numeric writability unavailable.)")
|
|
71
|
+
|
|
72
|
+
# --- numeric route 2: "which writer / tell me about FAMILY" -> atlas tool ---
|
|
73
|
+
fam = _family_in(question)
|
|
74
|
+
if fam:
|
|
75
|
+
import pandas as pd
|
|
76
|
+
|
|
77
|
+
from pen_stack.rag.index import _ATLAS
|
|
78
|
+
adf = pd.read_parquet(_ATLAS)
|
|
79
|
+
sub = adf[adf["family"] == fam]
|
|
80
|
+
rep = sub[sub["entry_kind"] == "curated_core"]
|
|
81
|
+
rep = rep.iloc[0] if len(rep) else sub.iloc[0]
|
|
82
|
+
provenance.append({"tool": "atlas.query", "args": {"family": fam},
|
|
83
|
+
"result": {"n_systems": int(len(sub)),
|
|
84
|
+
"reachability_tier": rep.get("reachability_tier"),
|
|
85
|
+
"deliv_class": rep.get("deliv_class")}})
|
|
86
|
+
parts.append(f"{fam}: {len(sub):,} catalogued systems; reachability {rep.get('reachability_tier')}; "
|
|
87
|
+
f"deliverability {rep.get('deliv_class')}; representative {rep['representative_system']}.")
|
|
88
|
+
|
|
89
|
+
# --- factual route: retrieval-grounded summary (always cited) ---
|
|
90
|
+
if retrieved:
|
|
91
|
+
parts.append("Relevant atlas facts: " + " | ".join(c.text for c in retrieved[:2]))
|
|
92
|
+
|
|
93
|
+
if not parts:
|
|
94
|
+
parts.append("No grounded match in the atlas. Try naming a writer family or a target gene.")
|
|
95
|
+
|
|
96
|
+
out = {"refused": False, "answer": " ".join(parts), "citations": citations,
|
|
97
|
+
"provenance": provenance, "disclaimer": DISCLAIMER}
|
|
98
|
+
out = enforce_grounded(out)
|
|
99
|
+
# optional LLM phrasing - presentation only; numbers/citations stay tool-derived (additive field)
|
|
100
|
+
if use_llm:
|
|
101
|
+
from pen_stack.rag.llm import phrase
|
|
102
|
+
phrased = phrase(out["answer"])
|
|
103
|
+
if phrased:
|
|
104
|
+
out["answer_phrased"] = phrased
|
|
105
|
+
return out
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""RAG retriever.
|
|
2
|
+
|
|
3
|
+
Loads the committed corpus + its committed embedding matrix and returns the top-k chunks for a query by EXACT
|
|
4
|
+
cosine over the embeddings (semantic, via the live query embedder), with a deterministic token-Jaccard fallback
|
|
5
|
+
when no embedder is reachable. Exact cosine over a committed matrix is the reproducible equivalent of a vector
|
|
6
|
+
index at this corpus scale (hundreds of chunks) - FAISS is unnecessary.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from pen_stack.rag.corpus import emb_path, load_corpus
|
|
15
|
+
from pen_stack.rag.embed import embed_query, lexical_scores
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@lru_cache(maxsize=1)
|
|
19
|
+
def _load():
|
|
20
|
+
df = load_corpus()
|
|
21
|
+
p = emb_path()
|
|
22
|
+
emb = None
|
|
23
|
+
if p.exists():
|
|
24
|
+
m = np.load(p).astype("float32")
|
|
25
|
+
if len(m) == len(df): # guard against a stale embedding matrix
|
|
26
|
+
emb = m
|
|
27
|
+
return df, emb
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def retrieve(query: str, k: int = 4) -> dict:
|
|
31
|
+
df, emb = _load()
|
|
32
|
+
texts = df["text"].tolist()
|
|
33
|
+
qv = embed_query(query) if emb is not None else None # LRU-cached query embedding
|
|
34
|
+
if qv is not None and emb is not None:
|
|
35
|
+
scores = emb @ qv # both L2-normalised -> dot product is cosine
|
|
36
|
+
method = "semantic (nomic-embed-text)"
|
|
37
|
+
else:
|
|
38
|
+
scores = lexical_scores(query, texts)
|
|
39
|
+
method = "lexical (jaccard fallback)"
|
|
40
|
+
order = [int(i) for i in np.argsort(-scores)[:k]]
|
|
41
|
+
hits = []
|
|
42
|
+
for i in order:
|
|
43
|
+
r = df.iloc[i]
|
|
44
|
+
hits.append({"chunk_id": str(r.chunk_id), "text": str(r.text), "source_id": str(r.source_id),
|
|
45
|
+
"doi": str(r.doi) if r.doi else "", "type": str(r.type),
|
|
46
|
+
"scope_status": str(r.scope_status), "score": float(scores[i])})
|
|
47
|
+
return {"query": query, "method": method, "hits": hits,
|
|
48
|
+
"top_score": float(scores[order[0]]) if order else 0.0, "n_corpus": len(df)}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Machine-readable rules engine for genome writing. Rules are versioned data in
|
|
2
|
+
configs/rules/*.yaml; evaluators (rules/evaluators.py) delegate to the existing validated functions; the
|
|
3
|
+
solver (rules/solver.py) returns legality + named reasons. Import evaluators so they register on load."""
|
|
4
|
+
from pen_stack.rules import evaluators as _evaluators # noqa: F401 (registers evaluators)
|
|
5
|
+
from pen_stack.rules.loader import load_ruleset
|
|
6
|
+
from pen_stack.rules.schema import Design, Rule, Ruleset
|
|
7
|
+
from pen_stack.rules.solver import evaluate, is_legal, legality_report
|
|
8
|
+
|
|
9
|
+
__all__ = ["load_ruleset", "Design", "Rule", "Ruleset", "evaluate", "is_legal", "legality_report"]
|