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/web/llm.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Hybrid grounded co-scientist (PEN-STACK).
|
|
2
|
+
|
|
3
|
+
PEN-STACK's ENGINE is the brain: for anything it can compute, the number comes from the engine and the grounding
|
|
4
|
+
guard strikes any value the model can't trace. On top of that the assistant has general + biological intelligence
|
|
5
|
+
for greetings and textbook questions, but those are answered in a SEPARATE, explicitly-labelled lane so a
|
|
6
|
+
general-knowledge fact can never be mistaken for a PEN-STACK result. Four lanes (see web.router.classify):
|
|
7
|
+
|
|
8
|
+
* design / explain → run the engine, narrate over the dossier + the metric guide (the guard runs; numbers are
|
|
9
|
+
the engine's, the guide explains what they MEAN, scale, direction, reference band).
|
|
10
|
+
* meta → answer about PEN-STACK itself from the LIVE capability facts (the guard runs over the facts).
|
|
11
|
+
* general → the LLM's trained knowledge, prefixed "general knowledge, not PEN-STACK-verified", with a
|
|
12
|
+
pointer to the engine wherever PEN-STACK could compute a concrete answer. No number is attributed to PEN-STACK.
|
|
13
|
+
|
|
14
|
+
Conversation memory: the last turns are passed in `history`, so follow-ups ("what does that 0.55 mean?") resolve
|
|
15
|
+
against the prior dossier. (The frontend keeps history in-session until refresh.)
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from types import SimpleNamespace
|
|
22
|
+
|
|
23
|
+
from pen_stack.web.guide import enrich_axes, guide_for, metric_guide, pen_stack_facts
|
|
24
|
+
from pen_stack.web.router import classify, pen_stack_angles
|
|
25
|
+
from pen_stack.web.tools import extract_grounded_numbers, run_tools
|
|
26
|
+
|
|
27
|
+
# system prompts
|
|
28
|
+
SYSTEM_GROUNDED = (
|
|
29
|
+
"You are PEN-STACK's co-scientist for genome writing. The PEN-STACK ENGINE computed the TOOL RESULTS; you "
|
|
30
|
+
"explain and route, but you MUST NOT state any number, score, probability, writer/enzyme name, or delivery "
|
|
31
|
+
"vehicle that is not present in the TOOL RESULTS or the METRIC GUIDE provided. NEVER invent a recommendation, "
|
|
32
|
+
"a markdown table of made-up values, or a 'hypothetical' result, if the engine did not compute something, "
|
|
33
|
+
"say so plainly. For every key number, explain what it MEANS in plain words, its scale, whether higher is "
|
|
34
|
+
"better, the reference band, how it was computed, so the user understands the value, not just sees it. "
|
|
35
|
+
"Surface uncertainty and the scope ledger ('what I can't tell you'). Be concise and friendly. "
|
|
36
|
+
"Decision-support, not a clinical directive."
|
|
37
|
+
)
|
|
38
|
+
SYSTEM_META = (
|
|
39
|
+
"You are PEN-STACK's co-scientist explaining the SYSTEM ITSELF (coverage, methods, accuracy). Answer using "
|
|
40
|
+
"ONLY the FACTS provided (counts, families, axes, how each is computed, the grounding posture). Do not invent "
|
|
41
|
+
"capabilities, counts, or accuracy claims. Be concise and precise; cite the numbers from the FACTS."
|
|
42
|
+
)
|
|
43
|
+
SYSTEM_GENERAL = (
|
|
44
|
+
"You are PEN-STACK's assistant answering a GENERAL biology / genome-engineering question from your own trained "
|
|
45
|
+
"knowledge. Answer clearly and helpfully at a graduate level. This is NOT a PEN-STACK calculation, so do NOT "
|
|
46
|
+
"present any number as a PEN-STACK result or imply it is engine-verified. If PEN-STACK could compute a concrete, "
|
|
47
|
+
"grounded answer for the user's case, mention that briefly. Keep it focused."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
_UNVERIFIED = "[unverified]"
|
|
51
|
+
_TOKEN_RE = re.compile(r"(?<![\w.])-?\d+(?:\.\d+)?%?")
|
|
52
|
+
_GENERAL_LABEL = " *General knowledge (from my training, not a PEN-STACK calculation):*"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# the grounding guard
|
|
56
|
+
def _is_grounded(token: str, grounded: set[str]) -> bool:
|
|
57
|
+
raw = token.strip()
|
|
58
|
+
pct = raw.endswith("%")
|
|
59
|
+
body = raw[:-1] if pct else raw
|
|
60
|
+
if body in grounded:
|
|
61
|
+
return True
|
|
62
|
+
try:
|
|
63
|
+
f = float(body)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return False
|
|
66
|
+
forms = {body, str(int(f)) if f.is_integer() else str(f), f"{f:.2f}"}
|
|
67
|
+
if pct:
|
|
68
|
+
forms.add(str(f / 100))
|
|
69
|
+
forms.add(f"{f / 100:.2f}")
|
|
70
|
+
return bool(forms & grounded)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _enforce_grounding(text: str, grounded: set[str]) -> str:
|
|
74
|
+
return _TOKEN_RE.sub(lambda m: m.group(0) if _is_grounded(m.group(0), grounded) else _UNVERIFIED, text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ungrounded_numbers(text: str, grounded: set[str]) -> list[str]:
|
|
78
|
+
return [m.group(0) for m in _TOKEN_RE.finditer(text) if not _is_grounded(m.group(0), grounded)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# deterministic narrators
|
|
82
|
+
def _fmt(x):
|
|
83
|
+
if isinstance(x, float):
|
|
84
|
+
return f"{x:.2f}".rstrip("0").rstrip(".") if x != int(x) else str(int(x))
|
|
85
|
+
return str(x)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _narrate_plan(p: dict) -> str | None:
|
|
89
|
+
"""The actual writer/site recommendation as a self-explanatory line (or an explicit 'not found' note)."""
|
|
90
|
+
if not p or not p.get("available"):
|
|
91
|
+
return None
|
|
92
|
+
if not p.get("found"):
|
|
93
|
+
return f"**Recommended writer.** {p.get('why', 'no writable plan for this target.')}"
|
|
94
|
+
bits = [f"the top writer family is **{p['recommended_writer']}**"]
|
|
95
|
+
site = p.get("site") or {}
|
|
96
|
+
if site.get("chrom"):
|
|
97
|
+
bits.append(f"at **{site['chrom']}:bin{site.get('bin')}**")
|
|
98
|
+
nums = []
|
|
99
|
+
if p.get("safety") is not None:
|
|
100
|
+
nums.append(f"safety {_fmt(p['safety'])}")
|
|
101
|
+
if p.get("durability") is not None:
|
|
102
|
+
nums.append(f"durability {_fmt(p['durability'])}")
|
|
103
|
+
if p.get("score") is not None:
|
|
104
|
+
nums.append(f"plan score {_fmt(p['score'])}")
|
|
105
|
+
cap = p.get("cargo_capacity_bp")
|
|
106
|
+
fit = p.get("cargo_fits_single_vector")
|
|
107
|
+
cargo_note = ""
|
|
108
|
+
if cap is not None:
|
|
109
|
+
cargo_note = (f" Cargo capacity is **{cap} bp**; the assembled cassette "
|
|
110
|
+
f"({p.get('assembled_bp', '?')} bp) "
|
|
111
|
+
+ ("fits a single vector" if fit else f"exceeds it → delivered as **{p.get('delivery', 'split/dual')}**")
|
|
112
|
+
+ ".")
|
|
113
|
+
alts = p.get("alternative_writers") or []
|
|
114
|
+
alt_note = f" Alternatives: {', '.join(alts)}." if alts else ""
|
|
115
|
+
return (f"**Recommended writer.** For this target {', '.join(bits)} "
|
|
116
|
+
f"({'; '.join(nums)}).{cargo_note}{alt_note} (0-1 scores, higher = better.)")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _deterministic_narrate(tr: dict) -> str:
|
|
120
|
+
d = tr["parsed_design"]
|
|
121
|
+
v = tr["verdict"]
|
|
122
|
+
lines = [f"I read your goal as a **{d['edit_intent'].replace('_', ' ')}** of **{d['gene']}** "
|
|
123
|
+
f"({d['chrom']}, ~{d['cargo_bp']} bp) by **{d['delivery_vehicle'].replace('_', ' ')}** "
|
|
124
|
+
f"in **{d['cell_type']}**. Every number below is engine-computed."]
|
|
125
|
+
plan_line = _narrate_plan(tr.get("plan") or {})
|
|
126
|
+
if plan_line:
|
|
127
|
+
lines.append(plan_line)
|
|
128
|
+
legal = "legal" if v["legal"] else ("deferred" if v["legal"] is None else "ILLEGAL")
|
|
129
|
+
line = f"**Verification.** The design is **{legal}** ({v['epistemic_status']})."
|
|
130
|
+
if v["violations"]:
|
|
131
|
+
line += " Violations: " + ", ".join(str(x) for x in v["violations"]) + "."
|
|
132
|
+
g = guide_for("confidence")
|
|
133
|
+
if v["confidence"] is not None:
|
|
134
|
+
band = f" [{v['interval'][0]:.2f}, {v['interval'][1]:.2f}]" if v.get("interval") else ""
|
|
135
|
+
line += f" Calibrated confidence: **{v['confidence']:.2f}**{band}, {g['means'].split('.')[0]}."
|
|
136
|
+
else:
|
|
137
|
+
line += " (Confidence abstained, no calibrated score for this design.)"
|
|
138
|
+
lines.append(line)
|
|
139
|
+
s = tr["safety"]
|
|
140
|
+
if s.get("decision"):
|
|
141
|
+
sd = (metric_guide().get("safety_decision") or {}).get(s["decision"], "")
|
|
142
|
+
lines.append(f"**Safety (Guardian).** **{s['decision']}**, {s.get('reason', '')}. {sd}")
|
|
143
|
+
axes = (tr["immune_profile"].get("axes") or {})
|
|
144
|
+
if axes:
|
|
145
|
+
lines.append("**Immune-risk profile** (per-axis, never collapsed; each is 0-1, higher = safer):")
|
|
146
|
+
for name, a in axes.items():
|
|
147
|
+
gd = guide_for(name) or {}
|
|
148
|
+
val = _fmt(a["value"]) if a.get("value") is not None else "n/a"
|
|
149
|
+
unc = f" ±{_fmt(a['uncertainty'])}" if a.get("uncertainty") is not None else ""
|
|
150
|
+
# prefer the self-explanatory engine 'meaning' (says what the value means + the proxy caveat in words)
|
|
151
|
+
meaning = a.get("meaning") or ((gd.get("means", "").split(".")[0]) if gd else "")
|
|
152
|
+
lines.append(f" • **{gd.get('label', name)}: {val}{unc}**, {meaning}")
|
|
153
|
+
ku = tr["immune_profile"].get("known_unknowns") or []
|
|
154
|
+
if ku:
|
|
155
|
+
lines.append("**What I can't tell you** (measured, never predicted): " + ", ".join(str(x) for x in ku) + ".")
|
|
156
|
+
lines.append(f"_{tr['disclaimer']}_")
|
|
157
|
+
return "\n".join(lines)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _deterministic_meta(facts: dict) -> str:
|
|
161
|
+
w = facts.get("writers", {})
|
|
162
|
+
dv = facts.get("delivery", {})
|
|
163
|
+
im = facts.get("immunogenicity", {})
|
|
164
|
+
ac = facts.get("accuracy", {})
|
|
165
|
+
lines = ["**What PEN-STACK covers (from the live engine):**"]
|
|
166
|
+
if w.get("systems"):
|
|
167
|
+
lines.append(f" • **Writers/enzymes:** {w['systems']} systems across {w['n_families']} families "
|
|
168
|
+
f"({', '.join(w.get('families', []))}).")
|
|
169
|
+
if dv.get("n_vehicles"):
|
|
170
|
+
lines.append(f" • **Delivery vehicles:** {dv['n_vehicles']} ({', '.join(dv.get('vehicles', []))}).")
|
|
171
|
+
if im.get("n_axes"):
|
|
172
|
+
lines.append(f" • **Immune-risk axes:** {im['n_axes']} ({', '.join(im.get('axes', []))}), never collapsed.")
|
|
173
|
+
lines.append(f" • **Accuracy posture:** {ac.get('posture', '')}")
|
|
174
|
+
lines.append(f" • **Not predicted:** {ac.get('what_is_NOT_predicted', '')}")
|
|
175
|
+
return "\n".join(lines)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_ALIASES = {"nab": "preexisting_nab", "antibod": "preexisting_nab", "seroprev": "preexisting_nab",
|
|
179
|
+
"peg": "anti_peg", "genotox": "genotoxicity", "oncogen": "genotoxicity", "epitope": "cd8_epitope",
|
|
180
|
+
"cd8": "cd8_epitope", "t-cell": "cd8_epitope", "t cell": "cd8_epitope", "innate": "innate",
|
|
181
|
+
"cpg": "innate", "confidence": "confidence", "interval": "confidence", "expression": "relative_expression",
|
|
182
|
+
"durab": "relative_expression"}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _explain_guides(message: str) -> dict:
|
|
186
|
+
"""Which metric cards a follow-up is asking about (by metric name or a common alias); all of them if unsure."""
|
|
187
|
+
low = message.lower()
|
|
188
|
+
metrics = metric_guide().get("metrics", {})
|
|
189
|
+
named = [k for k in metrics if k.replace("_", " ") in low or k in low]
|
|
190
|
+
named += [k for w, k in _ALIASES.items() if w in low and k not in named]
|
|
191
|
+
return {k: guide_for(k) for k in (named or list(metrics)) if guide_for(k)}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _deterministic_explain(guides: dict) -> str:
|
|
195
|
+
lines = ["**How to read these PEN-STACK metrics** (the values themselves come from the engine):"]
|
|
196
|
+
for k, g in guides.items():
|
|
197
|
+
if not g:
|
|
198
|
+
continue
|
|
199
|
+
lines.append(f" • **{g.get('label', k)}**, {g.get('scale', '')}, {g.get('direction', '')}. "
|
|
200
|
+
f"{g.get('means', '')} _Computed: {g.get('computed', '')}_ "
|
|
201
|
+
f"Bands: {g.get('bands', '')}. {g.get('reference', '')}")
|
|
202
|
+
lines.append(f"_{(metric_guide() or {}).get('disclaimer', '')}_")
|
|
203
|
+
return "\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _angles_footer(message: str) -> str:
|
|
207
|
+
angles = pen_stack_angles(message)
|
|
208
|
+
if not angles:
|
|
209
|
+
return ""
|
|
210
|
+
tips = " \n".join(f"• **{a['module']}**, try: _\"{a['example']}\"_" for a in angles)
|
|
211
|
+
return ("\n\n---\n **PEN-STACK can compute a grounded answer for your case** (not just general knowledge): \n"
|
|
212
|
+
+ tips)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# LLM backends
|
|
216
|
+
# The provider backends live in pen_stack/web/llm_provider.py (the swappable abstraction). The chat
|
|
217
|
+
# narrates through `run_llm`; the grounding guard below makes the GROUNDED result invariant to which provider fired.
|
|
218
|
+
def _run_llm(prompt, system, provider=None, kind="default"):
|
|
219
|
+
"""Delegate to the swappable provider abstraction; returns (text, backend) or (None, None).
|
|
220
|
+
kind="general" picks the fast-path timeout/token cap (Social / General lane); "default" keeps the longer budget
|
|
221
|
+
for engine-grounded design / explain / meta narration."""
|
|
222
|
+
from pen_stack.web.llm_provider import run_llm
|
|
223
|
+
return run_llm(prompt, system, provider=provider, kind=kind)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _history_block(history):
|
|
227
|
+
# P-WS3: each turn carries its lane/provenance in memory, so a follow-up resolves against the prior answer's
|
|
228
|
+
# grounding (e.g. interpreting an engine dossier) instead of being treated as fresh, unsourced text.
|
|
229
|
+
out = ""
|
|
230
|
+
for turn in (history or [])[-8:]:
|
|
231
|
+
role = turn.get("role", "user")
|
|
232
|
+
tag = f" [{turn.get('provenance')}]" if role == "assistant" and turn.get("provenance") else ""
|
|
233
|
+
out += f"{role.upper()}{tag}: {turn.get('content', '')}\n"
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# pre-route safety screen
|
|
238
|
+
# Defence-in-depth: a hazard-adjacent message gets the Guardian (biosecurity gate) BEFORE lane routing, so a
|
|
239
|
+
# hazardous request that would route to general/explain/meta (no design signal) is still screened, not only the
|
|
240
|
+
# design lane. The Guardian is framing-stripped and is the AUTHORITY on the decision; this broad regex only
|
|
241
|
+
# decides WHETHER to invoke it (benign chat skips it, and a false-positive trigger is harmless, the Guardian
|
|
242
|
+
# clears generic biology). Aligned with configs/safety/hazard_registry.yaml.
|
|
243
|
+
_HAZARD_RE = re.compile(
|
|
244
|
+
r"\b(toxin|ricin|abrin|botulin(?:um)?|bont|anthrax|protective[- ]antigen|lethal[- ]factor|edema[- ]factor|"
|
|
245
|
+
r"shiga|verotoxin|conotoxin|saxitoxin|tetrodotoxin|tetanus|diphtheria|cholera|enterotoxin|superantigen|"
|
|
246
|
+
r"ribosome[- ]?inactivating|clostridial|nerve[- ]agent|sarin|soman|tabun|vx|novichok|smallpox|variola|"
|
|
247
|
+
r"ebola|marburg|nipah|hendra|lassa|h[ae]?morrhagic[- ]fever|select[- ]agent|bioweapon|biological[- ]weapon|"
|
|
248
|
+
r"biothreat|gain[- ]of[- ]function|pathogen|virulence)\b", re.I)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _pre_route_safety(message: str):
|
|
252
|
+
"""If the message is hazard-adjacent, run the Guardian (framing-stripped) on it BEFORE lane routing. Returns a
|
|
253
|
+
SafetyVerdict when the decision is refuse/escalate (the caller short-circuits to a decline), else None.
|
|
254
|
+
|
|
255
|
+
Defence-in-depth: a hazard term combined with a BUILD/EXPRESS intent (an action verb) is escalated to a human
|
|
256
|
+
even when the specific agent is not in the Guardian's signature DB - the chat will not help express or design a
|
|
257
|
+
dual-use toxin/pathogen on the basis of generic language, and routes to biosecurity review instead."""
|
|
258
|
+
msg = message or ""
|
|
259
|
+
if not _HAZARD_RE.search(msg):
|
|
260
|
+
return None
|
|
261
|
+
try:
|
|
262
|
+
from pen_stack.safety import safety_gate
|
|
263
|
+
verdict = safety_gate({"cargo_function": msg.strip()}, actor="chat")
|
|
264
|
+
if getattr(verdict, "decision", None) in {"refuse", "escalate"}:
|
|
265
|
+
return verdict
|
|
266
|
+
except Exception: # noqa: BLE001 - the screen must never crash the chat; design lane still screens via run_tools
|
|
267
|
+
pass
|
|
268
|
+
from pen_stack.web.router import _ACTION
|
|
269
|
+
if _ACTION.search(msg):
|
|
270
|
+
return SimpleNamespace(decision="escalate", reason=(
|
|
271
|
+
"a build/express intent over a flagged dual-use hazard term; routed to human biosecurity review "
|
|
272
|
+
"(the specific agent need not be catalogued for the chat to decline)"))
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _safety_decline(verdict) -> dict:
|
|
277
|
+
reason = (getattr(verdict, "reason", None) or "matched a biosecurity hazard signature").strip()
|
|
278
|
+
reply = (
|
|
279
|
+
f" **Declined by the biosecurity screen (Guardian).** This request was assessed as **{verdict.decision}** "
|
|
280
|
+
f", {reason}\n\nPEN-STACK is a genome-writing co-scientist with a dual-use safety gate; it does not help "
|
|
281
|
+
"design, express, or enhance select-agent toxins or pathogens. If this was a general scientific question, "
|
|
282
|
+
"please rephrase it without a build/express intent.")
|
|
283
|
+
return {"mode": "safety", "provenance": "pen-stack", "grounded": True, "reply": reply, "backend": "guardian",
|
|
284
|
+
"tool_results": {"safety": {"decision": verdict.decision, "reason": verdict.reason}},
|
|
285
|
+
"angles": None, "facts": None}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# the public entry point
|
|
289
|
+
def grounded_reply(message: str, history: list | None = None, *, allow_llm: bool = True) -> dict:
|
|
290
|
+
"""Route the message to a lane and answer it. Returns {reply, mode, provenance, grounded, backend,
|
|
291
|
+
tool_results?, facts?, angles?}. The first three lanes are engine-grounded (guard ON); the 'general' lane is
|
|
292
|
+
explicitly labelled trained-knowledge and never attributes a number to PEN-STACK."""
|
|
293
|
+
# Defence-in-depth: screen hazard-adjacent messages with the Guardian BEFORE routing, so a hazardous request
|
|
294
|
+
# that would land in general/explain/meta (no design signal) is still refused, not only the design lane.
|
|
295
|
+
verdict = _pre_route_safety(message)
|
|
296
|
+
if verdict is not None:
|
|
297
|
+
return _safety_decline(verdict)
|
|
298
|
+
|
|
299
|
+
mode = classify(message, history)
|
|
300
|
+
hist = _history_block(history)
|
|
301
|
+
|
|
302
|
+
# ---- GENERAL: ANSWER general + social questions by default (labelled), with corpus retrieval ADDITIVE (RAG).
|
|
303
|
+
# A general answer is labelled 'general - not PEN-STACK-verified'; a corpus hit upgrades it to
|
|
304
|
+
# 'literature-cited' with sources; the system abstains ONLY on a specific unsourceable empirical claim. The
|
|
305
|
+
# safeguard mechanism is the per-lane LABEL (a general fact is never presented as a PEN-STACK result), not refusal. ----
|
|
306
|
+
if mode == "general":
|
|
307
|
+
angles = pen_stack_angles(message)
|
|
308
|
+
try:
|
|
309
|
+
from pen_stack.rag.ground import ground_general
|
|
310
|
+
g = ground_general(message, allow_llm=allow_llm)
|
|
311
|
+
except Exception: # noqa: BLE001 - retriever unavailable -> still answer as labelled general, never as a result
|
|
312
|
+
g = None
|
|
313
|
+
if g is not None:
|
|
314
|
+
base = {"mode": "general", "provenance": g["provenance"], "grounded": g["grounded"], "angles": angles,
|
|
315
|
+
"tool_results": None, "sources": g.get("sources"), "retrieval": g.get("retrieval"),
|
|
316
|
+
"status": g.get("status")}
|
|
317
|
+
return {**base, "reply": g["reply"] + _angles_footer(message), "backend": g["backend"]}
|
|
318
|
+
# the retriever itself failed: still answer from labelled general knowledge if the LLM is on (never present
|
|
319
|
+
# it as a PEN-STACK result), else point to the engine.
|
|
320
|
+
if allow_llm:
|
|
321
|
+
text, backend = _run_llm(f"USER: {message}\n\nAnswer from general knowledge, clearly.", SYSTEM_GENERAL,
|
|
322
|
+
kind="general") # fast-path: short timeout + token cap for the General lane
|
|
323
|
+
if text:
|
|
324
|
+
return {"mode": "general", "provenance": "general", "grounded": False, "angles": angles,
|
|
325
|
+
"tool_results": None, "sources": [],
|
|
326
|
+
"reply": _GENERAL_LABEL + "\n\n" + text.strip() + _angles_footer(message), "backend": backend}
|
|
327
|
+
return {"mode": "general", "provenance": "general", "grounded": False, "angles": angles, "tool_results": None,
|
|
328
|
+
"sources": [], "reply": _GENERAL_LABEL + "\n\nI can answer general questions when the language model "
|
|
329
|
+
"is enabled; the engine can also compute grounded, specific answers." + _angles_footer(message),
|
|
330
|
+
"backend": "deterministic"}
|
|
331
|
+
|
|
332
|
+
# ---- META: facts about PEN-STACK itself (grounded over the live facts) ----
|
|
333
|
+
if mode == "meta":
|
|
334
|
+
facts = pen_stack_facts()
|
|
335
|
+
allow = extract_grounded_numbers(facts)
|
|
336
|
+
base = {"mode": "meta", "provenance": "pen-stack", "grounded": True, "facts": facts, "tool_results": None}
|
|
337
|
+
if allow_llm:
|
|
338
|
+
prompt = (f"FACTS (the only source of numbers):\n{json.dumps(facts, default=str)}\n\n{hist}"
|
|
339
|
+
f"USER: {message}\n\nAnswer about PEN-STACK using only the FACTS.")
|
|
340
|
+
text, backend = _run_llm(prompt, SYSTEM_META)
|
|
341
|
+
if text:
|
|
342
|
+
return {**base, "reply": _enforce_grounding(text, allow), "backend": backend}
|
|
343
|
+
return {**base, "reply": _deterministic_meta(facts), "backend": "deterministic"}
|
|
344
|
+
|
|
345
|
+
# ---- EXPLAIN: interpret a value already on the table (the prior dossier lives in `history`) ----
|
|
346
|
+
if mode == "explain":
|
|
347
|
+
guides = _explain_guides(message)
|
|
348
|
+
# grounded = the numbers already in the conversation (engine-computed earlier) + the metric-guide numbers
|
|
349
|
+
allow = extract_grounded_numbers({"prior": hist, "guides": guides})
|
|
350
|
+
base = {"mode": "explain", "provenance": "pen-stack", "grounded": True, "tool_results": None,
|
|
351
|
+
"angles": None, "facts": None}
|
|
352
|
+
if allow_llm:
|
|
353
|
+
prompt = (f"METRIC GUIDE (how to read PEN-STACK's numbers):\n{json.dumps(guides, default=str)}\n\n"
|
|
354
|
+
f"CONVERSATION SO FAR (the numbers here were computed by the engine earlier):\n{hist}\n"
|
|
355
|
+
f"USER: {message}\n\nExplain what the value(s) mean using the metric guide, the scale, "
|
|
356
|
+
f"whether higher is better, the reference band, and how it was computed. Use ONLY numbers "
|
|
357
|
+
f"already in the conversation or the metric guide; do not introduce new ones.")
|
|
358
|
+
text, backend = _run_llm(prompt, SYSTEM_GROUNDED)
|
|
359
|
+
if text:
|
|
360
|
+
return {**base, "reply": _enforce_grounding(text, allow), "backend": backend}
|
|
361
|
+
return {**base, "reply": _deterministic_explain(guides), "backend": "deterministic"}
|
|
362
|
+
|
|
363
|
+
# ---- DESIGN: run the engine, narrate + interpret over the dossier (guard ON) ----
|
|
364
|
+
tr = run_tools(message, history)
|
|
365
|
+
tr["immune_profile"]["axes"] = enrich_axes(tr["immune_profile"].get("axes") or {})
|
|
366
|
+
guides = {k: guide_for(k) for k in (list((tr["immune_profile"].get("axes") or {}).keys())
|
|
367
|
+
+ ["confidence", "relative_expression"]) if guide_for(k)}
|
|
368
|
+
bands = metric_guide().get("safety_decision", {})
|
|
369
|
+
allow = extract_grounded_numbers(tr) | extract_grounded_numbers({"guides": guides})
|
|
370
|
+
base = {"mode": mode, "provenance": "pen-stack", "grounded": True, "tool_results": tr,
|
|
371
|
+
"angles": None, "facts": None}
|
|
372
|
+
if allow_llm:
|
|
373
|
+
prompt = (f"TOOL RESULTS (the only source of THIS design's numbers, including the recommended writer in "
|
|
374
|
+
f"`plan`):\n{json.dumps(tr, default=str)}\n\n"
|
|
375
|
+
f"METRIC GUIDE (use to EXPLAIN what each number means, scale, direction, reference band; you "
|
|
376
|
+
f"may cite band thresholds):\n{json.dumps(guides, default=str)}\n"
|
|
377
|
+
f"SAFETY DECISIONS:\n{json.dumps(bands)}\n\n{hist}USER: {message}\n\n"
|
|
378
|
+
f"Reply with: (1) the engine's findings WITH the numbers, if the user asked which writer, lead "
|
|
379
|
+
f"with `plan.recommended_writer` and its site/scores (do NOT name any other writer or vehicle), "
|
|
380
|
+
f"(2) what each key number MEANS in plain words (scale, what's good/bad, reference range), "
|
|
381
|
+
f"(3) the uncertainty + scope ledger. STRICT: use ONLY numbers present in the tool results or the "
|
|
382
|
+
f"metric guide; do NOT invent a writer name, a vehicle, a table, or any number. If something "
|
|
383
|
+
f"isn't in the tool results, say it is not computed.")
|
|
384
|
+
text, backend = _run_llm(prompt, SYSTEM_GROUNDED)
|
|
385
|
+
if text:
|
|
386
|
+
cleaned = _enforce_grounding(text, allow)
|
|
387
|
+
# Defence against fabrication-spam: if the model invented a lot of numbers (the guard struck many),
|
|
388
|
+
# the narrated reply is unreadable ([unverified] everywhere), fall back to the deterministic,
|
|
389
|
+
# fully-grounded narration so the user always gets a clean, traceable answer.
|
|
390
|
+
if cleaned.count(_UNVERIFIED) >= 2:
|
|
391
|
+
return {**base, "reply": _deterministic_narrate(tr), "backend": f"deterministic (guard:{backend})"}
|
|
392
|
+
return {**base, "reply": cleaned, "backend": backend}
|
|
393
|
+
return {**base, "reply": _deterministic_narrate(tr), "backend": "deterministic"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Swappable LLM provider abstraction.
|
|
2
|
+
|
|
3
|
+
ONE interface over the local Ollama and cloud Nemotron backends (and any future provider). The LLM is
|
|
4
|
+
NON-LOAD-BEARING: the grounding guard, the engine tool results, and the retrieved sources carry truth, so the
|
|
5
|
+
system's GROUNDED outputs - the lane, the provenance, the cited sources, and every number - are INVARIANT to which
|
|
6
|
+
provider answers; only the prose phrasing changes. Centralising provider selection here makes that invariance
|
|
7
|
+
testable and the default documented.
|
|
8
|
+
|
|
9
|
+
Selection:
|
|
10
|
+
* PEN_STACK_LLM_PROVIDER pins a single provider (used by the invariance test);
|
|
11
|
+
* else PEN_STACK_LLM_ORDER (default "ollama,nemotron") is tried in order;
|
|
12
|
+
* PEN_STACK_NO_LLM=1 disables the LLM entirely (the deterministic narrators take over).
|
|
13
|
+
The default provider is **ollama** (local, reproducible, no key); Nemotron is the cloud fallback.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
DEFAULT_ORDER = "ollama,nemotron"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _ollama_base() -> str:
|
|
24
|
+
return os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# latency fix: the General / Social lane never needs the long-form 450-token answer the engine-grounded lanes
|
|
28
|
+
# may need, and it does not benefit from a 150 s hang waiting on a stalled provider. The kind="general" path uses a
|
|
29
|
+
# shorter timeout AND a shorter num_predict; the default path (engine-grounded design / explain / meta) is unchanged.
|
|
30
|
+
def _llm_timeout(kind: str = "default") -> float:
|
|
31
|
+
if kind == "general":
|
|
32
|
+
return float(os.getenv("PEN_STACK_LLM_TIMEOUT_GENERAL", "45"))
|
|
33
|
+
return float(os.getenv("PEN_STACK_LLM_TIMEOUT", "90"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ollama_num_predict(kind: str = "default") -> int:
|
|
37
|
+
if kind == "general":
|
|
38
|
+
return int(os.getenv("OLLAMA_NUM_PREDICT_GENERAL", "280"))
|
|
39
|
+
return int(os.getenv("OLLAMA_NUM_PREDICT", "450"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _nemotron_max_tokens(kind: str = "default") -> int:
|
|
43
|
+
if kind == "general":
|
|
44
|
+
return int(os.getenv("NEMOTRON_MAX_TOKENS_GENERAL", "400"))
|
|
45
|
+
return int(os.getenv("NEMOTRON_MAX_TOKENS", "700"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _call_ollama(prompt: str, system: str, kind: str = "default") -> str:
|
|
49
|
+
import requests
|
|
50
|
+
r = requests.post(
|
|
51
|
+
f"{_ollama_base()}/api/generate",
|
|
52
|
+
json={"model": os.getenv("OLLAMA_MODEL", "qwen2.5:3b-instruct"), "prompt": prompt, "system": system,
|
|
53
|
+
"stream": False, "keep_alive": os.getenv("OLLAMA_KEEP_ALIVE", "30m"),
|
|
54
|
+
"options": {"temperature": 0.2, "num_predict": _ollama_num_predict(kind)}},
|
|
55
|
+
timeout=_llm_timeout(kind))
|
|
56
|
+
r.raise_for_status()
|
|
57
|
+
return r.json()["response"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _nvidia_key() -> str | None:
|
|
61
|
+
key = os.getenv("NVIDIA_API_KEY")
|
|
62
|
+
if key:
|
|
63
|
+
return key.strip()
|
|
64
|
+
f = Path(__file__).resolve().parents[2] / "configs" / "nvidia_api_key.txt"
|
|
65
|
+
return f.read_text(encoding="utf-8").strip() if f.exists() else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _call_nemotron(prompt: str, system: str, kind: str = "default") -> str:
|
|
69
|
+
import requests
|
|
70
|
+
key = _nvidia_key()
|
|
71
|
+
if not key:
|
|
72
|
+
raise RuntimeError("no NVIDIA_API_KEY")
|
|
73
|
+
r = requests.post(
|
|
74
|
+
"https://integrate.api.nvidia.com/v1/chat/completions",
|
|
75
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
76
|
+
json={"model": os.getenv("NEMOTRON_MODEL", "nvidia/llama-3.3-nemotron-super-49b-v1"),
|
|
77
|
+
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
|
78
|
+
"temperature": 0.2, "max_tokens": _nemotron_max_tokens(kind)}, timeout=_llm_timeout(kind))
|
|
79
|
+
r.raise_for_status()
|
|
80
|
+
return r.json()["choices"][0]["message"]["content"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_BACKENDS = {"ollama": _call_ollama, "nemotron": _call_nemotron}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def providers() -> list[str]:
|
|
87
|
+
"""The registered providers (names accepted by `run_llm(provider=...)`)."""
|
|
88
|
+
return list(_BACKENDS.keys())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _order() -> list[str]:
|
|
92
|
+
pinned = os.getenv("PEN_STACK_LLM_PROVIDER")
|
|
93
|
+
if pinned:
|
|
94
|
+
return [pinned.strip().lower()]
|
|
95
|
+
return [b.strip().lower() for b in os.getenv("PEN_STACK_LLM_ORDER", DEFAULT_ORDER).split(",")]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def default_provider() -> str:
|
|
99
|
+
return _order()[0]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run_llm(prompt: str, system: str, provider: str | None = None,
|
|
103
|
+
kind: str = "default") -> tuple[str | None, str | None]:
|
|
104
|
+
"""Return (text, backend_name) or (None, None). If `provider` is given, use ONLY it (invariance testing);
|
|
105
|
+
otherwise try the configured order. `kind="general"` selects the fast-path timeout / token cap (Social / General
|
|
106
|
+
lane); `kind="default"` (the engine-grounded lanes) keeps the longer budget. The downstream guard makes the
|
|
107
|
+
GROUNDED result invariant to which provider fired."""
|
|
108
|
+
if os.getenv("PEN_STACK_NO_LLM") == "1":
|
|
109
|
+
return None, None
|
|
110
|
+
order = [provider.strip().lower()] if provider else _order()
|
|
111
|
+
for name in order:
|
|
112
|
+
fn = _BACKENDS.get(name)
|
|
113
|
+
if fn is None:
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
return fn(prompt, system, kind=kind), name
|
|
117
|
+
except Exception: # noqa: BLE001 - a dead provider falls through to the next, else the deterministic path
|
|
118
|
+
continue
|
|
119
|
+
return None, None
|
pen_stack/web/router.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Intent router for the hybrid co-scientist. Deterministic (no LLM) so routing is reproducible.
|
|
2
|
+
|
|
3
|
+
Four lanes, each with its own provenance:
|
|
4
|
+
* ``design``, a genome-writing request → RUN the engine (grounded numbers, guard ON).
|
|
5
|
+
* ``explain``, a follow-up about a value already on the table ("what does that 0.55 mean") → metric guide +
|
|
6
|
+
the prior dossier (grounded interpretation).
|
|
7
|
+
* ``meta``, a question about PEN-STACK itself ("how many enzymes? how is immunogenicity computed? how
|
|
8
|
+
accurate?") → the live capability facts (grounded).
|
|
9
|
+
* ``general``, greetings + textbook biology ("hi", "what is AAV", "how does AAV work") → the LLM's trained
|
|
10
|
+
knowledge, EXPLICITLY labelled "general knowledge, not PEN-STACK-verified", with a pointer to
|
|
11
|
+
the engine wherever PEN-STACK could compute something concrete.
|
|
12
|
+
|
|
13
|
+
Bias: when a real design signal is present we route to ``design`` (the engine path is cheap and always grounds),
|
|
14
|
+
so a genuine request is never answered from ungrounded general knowledge.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
_ACTION = re.compile(r"\b(insert|express|knock[\s-]?in|knock[\s-]?out|integrat|excis|deliver|edit|write|"
|
|
21
|
+
r"target|disrupt|correct|replace|place|add)\w*\b", re.I)
|
|
22
|
+
_GENE_RE = re.compile(r"\b([A-Z][A-Z0-9]{1,7})\b")
|
|
23
|
+
_VEHICLE = re.compile(r"\b(aav|lentivir|lenti|lnp|mrna|adenovir|hsv|electroporat|vector|capsid|vehicle)\w*\b", re.I)
|
|
24
|
+
# trailing \w* so inflected forms count as a signal too (sites, hepatocytes, HSPCs, iPSCs)
|
|
25
|
+
_LOCUS = re.compile(r"\b(locus|loci|site|safe[\s-]?harbour|safe[\s-]?harbor|aavs1|chr\d|enhancer|intron|exon)\w*\b", re.I)
|
|
26
|
+
_CELL = re.compile(r"\b(hepatocyt|liver|hspc|stem cell|ipsc|t[\s-]?cell|k562|hepg2|pbmc|cell line|cell type)\w*\b", re.I)
|
|
27
|
+
_CARGO = re.compile(r"\b(\d+(?:\.\d+)?\s*kb|\d{3,6}\s*bp|cargo|cassette|transgene|payload)\b", re.I)
|
|
28
|
+
|
|
29
|
+
_EXPLAIN = re.compile(r"\b(what (do|does|is) (that|these|those|this|the)\b|what do the\b|"
|
|
30
|
+
r"that (number|value|score)|those (numbers|values|scores)|the \d+(\.\d+)?\b|"
|
|
31
|
+
r"explain (that|this|the)|interpret|what does (it|that) mean|"
|
|
32
|
+
r"reference range|what range|is that (good|bad|high|low|safe)|how do i read)\b", re.I)
|
|
33
|
+
|
|
34
|
+
_META = re.compile(r"\b(pen[\s-]?stack|how (do|does) (you|pen)|how is .* (computed|calculated|derived|scored)|"
|
|
35
|
+
r"how many (?:[\w-]+\s+){0,2}(enzyme|writer|vector|vehicle|ax(?:e|is|es)|immune|metric|model|"
|
|
36
|
+
r"system|locus|loci|gene|cell|oracle)\w*|"
|
|
37
|
+
r"how accurate|accuracy|how reliable|validated|what (can|do) you (do|cover|offer|support)|"
|
|
38
|
+
r"your (coverage|capabilit|method|model|data|engine)|how (does|is) immunogenicity|"
|
|
39
|
+
r"what (model|oracle|data|dataset)s?\b|where do (the|these) (number|value)s? come from|"
|
|
40
|
+
r"how (do|does) (it|you|pen[\s-]?stack) (give|compute|calculate|get))\b", re.I)
|
|
41
|
+
|
|
42
|
+
_GREETING = re.compile(r"^\s*(hi|hii+|hey|hello|yo|hola|greetings|good (morning|afternoon|evening)|thanks?|"
|
|
43
|
+
r"thank you|ok(ay)?|cool|nice|great)\b", re.I)
|
|
44
|
+
|
|
45
|
+
# A back-reference follow-up to the previous answer ("and why?", "is that it?", "tell me more about that").
|
|
46
|
+
_FOLLOWUP = re.compile(r"^\s*(and |but |so |then |why|how come|really|are you sure|elaborate|tell me more|"
|
|
47
|
+
r"go on|more|continue|expand|what about)\b|"
|
|
48
|
+
r"\b(that|it|this|those|these|them|the (score|number|value|result|design|writer|site|plan))\b",
|
|
49
|
+
re.I)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _prior_lane(history: list | None) -> str | None:
|
|
53
|
+
"""The lane of the most recent ASSISTANT turn (carried in memory), or None."""
|
|
54
|
+
for turn in reversed(history or []):
|
|
55
|
+
if turn.get("role") == "assistant" and turn.get("mode"):
|
|
56
|
+
return str(turn.get("mode"))
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _looks_like_design(message: str) -> bool:
|
|
61
|
+
"""A real genome-writing request. CONSERVATIVE BY DESIGN (gate P-G2): a write/result request must never leak to
|
|
62
|
+
the ungrounded general lane, so an explicit ACTION verb (insert/integrate/correct/knock-in/...) plus ANY ONE
|
|
63
|
+
target signal (vehicle / locus / cell / cargo / a gene) routes to the grounded design lane. Without an action
|
|
64
|
+
verb we still catch noun-phrase designs (vehicle + cargo/locus). Over-routing a borderline question to a
|
|
65
|
+
grounded lane is the safe failure direction; under-routing a real request to 'general' is the dangerous one."""
|
|
66
|
+
genes = [g for g in _GENE_RE.findall(message) if g not in {"DNA", "RNA", "AAV", "LNP", "HSV", "CAR", "RNP", "PEG"}]
|
|
67
|
+
signals = sum(bool(x) for x in (_VEHICLE.search(message), _LOCUS.search(message), _CELL.search(message),
|
|
68
|
+
_CARGO.search(message), genes))
|
|
69
|
+
if _ACTION.search(message):
|
|
70
|
+
return signals >= 1
|
|
71
|
+
# no action verb: a noun-phrase design needs a vehicle + a cargo/locus ("AAV delivery of a 3 kb cassette")
|
|
72
|
+
return bool(_VEHICLE.search(message)) and bool(_CARGO.search(message) or _LOCUS.search(message))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def classify(message: str, history: list | None = None) -> str:
|
|
76
|
+
msg = (message or "").strip()
|
|
77
|
+
has_history = bool(history)
|
|
78
|
+
if _looks_like_design(msg):
|
|
79
|
+
return "design"
|
|
80
|
+
if has_history and _EXPLAIN.search(msg) and not _META.search(msg):
|
|
81
|
+
return "explain"
|
|
82
|
+
if _META.search(msg):
|
|
83
|
+
return "meta"
|
|
84
|
+
if _EXPLAIN.search(msg) and has_history: # follow-up interpretation even w/o explicit prior-number
|
|
85
|
+
return "explain"
|
|
86
|
+
# P-WS3 lane-aware memory: a back-reference follow-up to a GROUNDED answer (design/explain/meta) stays grounded
|
|
87
|
+
# in the explain lane rather than silently downgrading to the (now retrieval-gated) general lane.
|
|
88
|
+
if _prior_lane(history) in ("design", "explain", "meta") and _FOLLOWUP.search(msg) and not _GREETING.search(msg):
|
|
89
|
+
return "explain"
|
|
90
|
+
return "general"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# topic -> (PEN-STACK module, an example you can actually run)
|
|
94
|
+
_ANGLES = [
|
|
95
|
+
(re.compile(r"\b(vector|delivery|aav|lentivir|lnp|capsid|vehicle|serotype)\w*\b", re.I),
|
|
96
|
+
"Delivery & Immunity", "compare AAV vs lentivirus for durable liver expression"),
|
|
97
|
+
(re.compile(r"\b(safe[\s-]?harbo|locus|loci|integration site|where (to|can) (i )?(write|insert))\b", re.I),
|
|
98
|
+
"Site Finder", "score safe-harbour loci for AAVS1 in hepatocytes"),
|
|
99
|
+
(re.compile(r"\b(enzyme|writer|integrase|recombinase|nuclease|transposase|cas9|bxb1|paste|prime editor)\w*\b", re.I),
|
|
100
|
+
"Writer Atlas", "which writer can integrate a 3 kb cassette at albumin in hepatocytes"),
|
|
101
|
+
(re.compile(r"\b(immunogenic|immune|antibod|nab|seroprevalen|t[\s-]?cell|anti[\s-]?peg|genotox)\w*\b", re.I),
|
|
102
|
+
"Delivery & Immunity", "immune-risk profile of an AAV insertion in adult liver"),
|
|
103
|
+
(re.compile(r"\b(express|expression|durab|titer|outcome|how (well|much)|efficacy)\w*\b", re.I),
|
|
104
|
+
"Digital Twin", "predict the relative outcome for an AAVS1 insertion in hepatocytes"),
|
|
105
|
+
(re.compile(r"\b(safe|hazard|biosecurit|dual[\s-]?use|toxin|legal|allowed)\w*\b", re.I),
|
|
106
|
+
"Guardian / Verify", "is it safe and legal to express factor IX from an AAV"),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def pen_stack_angles(message: str, limit: int = 2) -> list[dict]:
|
|
111
|
+
"""For a general question, which PEN-STACK modules could compute a concrete, grounded answer (+ an example)."""
|
|
112
|
+
out, seen = [], set()
|
|
113
|
+
for rx, module, example in _ANGLES:
|
|
114
|
+
if rx.search(message) and module not in seen:
|
|
115
|
+
out.append({"module": module, "example": example})
|
|
116
|
+
seen.add(module)
|
|
117
|
+
if len(out) >= limit:
|
|
118
|
+
break
|
|
119
|
+
return out
|