feat(runtime): add runtime Python server (#4106)

This commit is contained in:
Steven Enamakel
2026-06-25 09:24:13 -07:00
committed by GitHub
parent 96437120e5
commit 8e8ea4c05e
13 changed files with 1107 additions and 613 deletions
+49 -5
View File
@@ -7,7 +7,8 @@
//! Current steps (all non-required — failure degrades to a fallback):
//! 1. `python_runtime` — managed CPython (prerequisite for spaCy).
//! 2. `spacy` — spaCy venv + `en_core_web_sm` model.
//! 3. `node_runtime` — managed Node.js (skills / MCP).
//! 3. `runtime_python_server` — long-running Python backend host.
//! 4. `node_runtime` — managed Node.js (skills / MCP).
//!
//! Voice models (Whisper, Piper) and Ollama stay lazy/opt-in and are
//! intentionally NOT registered here; they can be added later as steps.
@@ -38,7 +39,12 @@ pub struct HarnessInitStep {
/// The ordered list of mandatory eager steps.
pub fn all_steps() -> Vec<HarnessInitStep> {
vec![python_runtime_step(), spacy_step(), node_runtime_step()]
vec![
python_runtime_step(),
spacy_step(),
runtime_python_server_step(),
node_runtime_step(),
]
}
// ── python_runtime ──────────────────────────────────────────────────────────
@@ -86,6 +92,36 @@ async fn python_run(config: &Config) -> Result<(), String> {
// ── spacy ─────────────────────────────────────────────────────────────────
fn runtime_python_server_step() -> HarnessInitStep {
HarnessInitStep {
id: "runtime_python_server",
label: "Runtime Python server",
required: false,
is_done: |config| Box::pin(runtime_python_server_is_done(config)),
run: |config| Box::pin(runtime_python_server_run(config)),
}
}
async fn runtime_python_server_is_done(config: &Config) -> bool {
if crate::openhuman::runtime_python_server::enabled_backends(config).is_empty() {
return true;
}
let status = crate::openhuman::runtime_python_server::status().await;
status.running
}
async fn runtime_python_server_run(config: &Config) -> Result<(), String> {
if crate::openhuman::runtime_python_server::enabled_backends(config).is_empty() {
return Ok(());
}
crate::openhuman::runtime_python_server::ensure_started(config)
.await
.map(|_| {
log::info!("[harness_init] runtime Python server ready");
})
.map_err(|e| format!("{e:#}"))
}
fn spacy_step() -> HarnessInitStep {
HarnessInitStep {
id: "spacy",
@@ -100,14 +136,14 @@ async fn spacy_is_done(config: &Config) -> bool {
if !config.runtime_python.enabled || !config.memory_tree.spacy_enabled {
return true;
}
crate::openhuman::memory_tree::nlp::spacy_provisioned(config)
crate::openhuman::runtime_python_server::spacy_provisioned(config)
}
async fn spacy_run(config: &Config) -> Result<(), String> {
if !config.runtime_python.enabled || !config.memory_tree.spacy_enabled {
return Ok(());
}
crate::openhuman::memory_tree::nlp::ensure_spacy(config)
crate::openhuman::runtime_python_server::ensure_spacy(config)
.await
.map(|_| {
log::info!("[harness_init] spaCy provisioned");
@@ -167,7 +203,15 @@ mod tests {
fn all_steps_have_stable_ids_and_are_non_required() {
let steps = all_steps();
let ids: Vec<_> = steps.iter().map(|s| s.id).collect();
assert_eq!(ids, vec!["python_runtime", "spacy", "node_runtime"]);
assert_eq!(
ids,
vec![
"python_runtime",
"spacy",
"runtime_python_server",
"node_runtime"
]
);
assert!(steps.iter().all(|s| !s.required));
assert!(steps.iter().all(|s| !s.label.is_empty()));
}
-227
View File
@@ -1,227 +0,0 @@
//! Long-lived stdio client for the spaCy NER service.
//!
//! Spawns `service.py` under the provisioned venv interpreter, reads the one
//! `{"ready": true}` handshake line, then issues one request per query over a
//! mutex-guarded stdin/stdout pair. The model loads once for the life of the
//! process; queries are cheap line round-trips.
//!
//! A process-global [`OnceCell`] memoises the (possibly failed) initialisation
//! so the expensive provisioning + model load happens at most once. If init
//! fails (no Python, spaCy install failed, model load error) the cell stores
//! `None` and every caller falls back to the in-Rust extractor for the rest of
//! the process lifetime.
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::{Mutex, OnceCell};
use crate::openhuman::config::Config;
use crate::openhuman::memory_tree::nlp::provision::{ensure_spacy, SpacyRuntime};
/// Per-request timeout for a single extraction round-trip. The model is
/// already loaded; extraction of a short query is sub-100ms, so this is just a
/// guard against a wedged child.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// One named entity span as reported by spaCy.
#[derive(Debug, Clone, Deserialize)]
pub struct SpacyEntity {
pub text: String,
pub label: String,
#[serde(default)]
pub start: u32,
#[serde(default)]
pub end: u32,
}
/// Parsed extraction response for one query.
#[derive(Debug, Clone, Deserialize)]
pub struct SpacyResponse {
#[serde(default)]
pub entities: Vec<SpacyEntity>,
#[serde(default)]
pub nouns: Vec<String>,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Deserialize)]
struct ReadyLine {
#[serde(default)]
ready: bool,
#[serde(default)]
error: Option<String>,
}
/// Mutable I/O state for the child, guarded by a mutex so requests serialise.
struct Inner {
// `_child` is retained so the process stays alive (and is killed on drop).
_child: Child,
stdin: ChildStdin,
stdout: Lines<BufReader<ChildStdout>>,
next_id: u64,
}
/// Handle to a running spaCy NER service.
pub struct SpacyNer {
inner: Mutex<Inner>,
}
impl SpacyNer {
/// Spawn the service and complete the readiness handshake.
async fn spawn(runtime: &SpacyRuntime) -> Result<Self> {
log::debug!(
"[memory_tree::nlp] spawning spaCy service python={} script={}",
runtime.python_bin.display(),
runtime.service_script.display()
);
let mut child = Command::new(&runtime.python_bin)
.arg("-u")
.arg(&runtime.service_script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.with_context(|| "spawning spaCy service process")?;
let stdin = child.stdin.take().context("spaCy child stdin missing")?;
let stdout = child.stdout.take().context("spaCy child stdout missing")?;
let mut lines = BufReader::new(stdout).lines();
// Handshake: wait for the ready line.
let ready_line =
match tokio::time::timeout(Duration::from_secs(30), lines.next_line()).await {
Ok(Ok(Some(line))) => line,
Ok(Ok(None)) => bail!("spaCy service exited before readiness handshake"),
Ok(Err(e)) => return Err(e).context("reading spaCy readiness line"),
Err(_) => bail!("spaCy service readiness handshake timed out"),
};
let ready: ReadyLine = serde_json::from_str(&ready_line)
.with_context(|| format!("parsing spaCy ready line: {ready_line}"))?;
if !ready.ready {
bail!(
"spaCy service failed to load model: {}",
ready.error.unwrap_or_else(|| "unknown".into())
);
}
log::info!("[memory_tree::nlp] spaCy service ready");
Ok(Self {
inner: Mutex::new(Inner {
_child: child,
stdin,
stdout: lines,
next_id: 0,
}),
})
}
/// Extract named entities + salient nouns from `text`.
pub async fn extract(&self, text: &str) -> Result<SpacyResponse> {
let mut guard = self.inner.lock().await;
let id = guard.next_id;
guard.next_id += 1;
let id_str = id.to_string();
let req = serde_json::json!({ "id": id_str, "text": text });
let mut line = serde_json::to_string(&req)?;
line.push('\n');
guard
.stdin
.write_all(line.as_bytes())
.await
.context("writing spaCy request")?;
guard
.stdin
.flush()
.await
.context("flushing spaCy request")?;
// Read until the response matching our id (skip stray lines).
loop {
let next = tokio::time::timeout(REQUEST_TIMEOUT, guard.stdout.next_line()).await;
let line = match next {
Ok(Ok(Some(l))) => l,
Ok(Ok(None)) => bail!("spaCy service closed mid-request"),
Ok(Err(e)) => return Err(e).context("reading spaCy response"),
Err(_) => bail!("spaCy request timed out"),
};
let resp: SpacyResponse = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
log::warn!("[memory_tree::nlp] unparseable spaCy line skipped: {e}");
continue;
}
};
if resp.id.as_deref() == Some(id_str.as_str()) {
if let Some(err) = &resp.error {
bail!("spaCy extraction error: {err}");
}
return Ok(resp);
}
// Different id — keep reading.
}
}
}
/// Process-global memoised NER handle. `None` means initialisation was
/// attempted and failed; callers fall back to the Rust extractor.
static SPACY: OnceCell<Option<Arc<SpacyNer>>> = OnceCell::const_new();
/// Get the shared spaCy NER handle, initialising (provision + spawn) on first
/// call. Returns `None` when spaCy is unavailable; never errors so callers can
/// branch cleanly to the fallback path.
pub async fn shared_ner(config: &Config) -> Option<Arc<SpacyNer>> {
SPACY
.get_or_init(|| async {
match init_ner(config).await {
Ok(ner) => Some(Arc::new(ner)),
Err(e) => {
log::warn!("[memory_tree::nlp] spaCy unavailable, using Rust fallback: {e:#}");
None
}
}
})
.await
.clone()
}
async fn init_ner(config: &Config) -> Result<SpacyNer> {
let runtime = ensure_spacy(config).await?;
SpacyNer::spawn(&runtime).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ready_line_parses() {
let r: ReadyLine =
serde_json::from_str(r#"{"ready":true,"model":"en_core_web_sm"}"#).unwrap();
assert!(r.ready);
let bad: ReadyLine = serde_json::from_str(r#"{"ready":false,"error":"no spacy"}"#).unwrap();
assert!(!bad.ready);
assert_eq!(bad.error.as_deref(), Some("no spacy"));
}
#[test]
fn response_parses_entities_and_nouns() {
let resp: SpacyResponse = serde_json::from_str(
r#"{"id":"0","entities":[{"text":"Alice","label":"PERSON","start":0,"end":5}],"nouns":["migration","runbook"]}"#,
)
.unwrap();
assert_eq!(resp.entities.len(), 1);
assert_eq!(resp.entities[0].label, "PERSON");
assert_eq!(resp.nouns, vec!["migration", "runbook"]);
}
}
+29 -32
View File
@@ -2,7 +2,8 @@
//!
//! [`extract_query_entities`] turns a natural-language query into a set of
//! canonical entity ids that key into `mem_tree_entity_index` and the
//! co-occurrence graph. It prefers a spaCy NER sidecar (named entities +
//! co-occurrence graph. It prefers the runtime Python server's spaCy backend
//! (named entities +
//! salient nouns) and falls back to the in-Rust regex extractor whenever
//! spaCy is disabled or unavailable — so retrieval always works offline, just
//! with lower person/org recall.
@@ -12,11 +13,9 @@
//! same `<kind>:<value>` namespace as the indexed chunk entities. No id
//! mismatch, no bespoke join.
mod client;
mod provision;
pub use client::{shared_ner, SpacyNer};
pub use provision::{ensure_spacy, spacy_provisioned, SpacyRuntime, SPACY_MODEL};
pub use crate::openhuman::runtime_python_server::{
ensure_spacy, spacy_provisioned, SpacyResponse, SPACY_MODEL,
};
use crate::openhuman::config::Config;
use crate::openhuman::memory_tree::score::extract::{
@@ -51,22 +50,20 @@ pub async fn extract_query_entities(config: &Config, query: &str) -> Vec<Canonic
}
if config.memory_tree.spacy_enabled {
if let Some(ner) = shared_ner(config).await {
match ner.extract(trimmed).await {
Ok(resp) => {
let extracted = spacy_to_extracted(&resp);
let canon = canonicalise(&extracted);
log::debug!(
"[memory_tree::nlp] spaCy query extraction: entities={} nouns={} canonical={}",
resp.entities.len(),
resp.nouns.len(),
canon.len()
);
return canon;
}
Err(e) => {
log::warn!("[memory_tree::nlp] spaCy extraction failed, falling back: {e:#}");
}
match crate::openhuman::runtime_python_server::extract_spacy(config, trimmed).await {
Ok(resp) => {
let extracted = spacy_to_extracted(&resp);
let canon = canonicalise(&extracted);
log::debug!(
"[memory_tree::nlp] spaCy query extraction: entities={} nouns={} canonical={}",
resp.entities.len(),
resp.nouns.len(),
canon.len()
);
return canon;
}
Err(e) => {
log::warn!("[memory_tree::nlp] spaCy extraction failed, falling back: {e:#}");
}
}
} else {
@@ -79,7 +76,7 @@ pub async fn extract_query_entities(config: &Config, query: &str) -> Vec<Canonic
/// Build [`ExtractedEntities`] from a spaCy response: named entities become
/// entity spans, salient nouns become topics. Topics are promoted to
/// `topic:<noun>` canonical ids by [`canonicalise`].
fn spacy_to_extracted(resp: &client::SpacyResponse) -> ExtractedEntities {
fn spacy_to_extracted(resp: &SpacyResponse) -> ExtractedEntities {
let entities = resp
.entities
.iter()
@@ -170,16 +167,16 @@ mod tests {
#[test]
fn spacy_response_maps_nouns_to_topics() {
let resp = client::SpacyResponse {
entities: vec![client::SpacyEntity {
text: "Alice".into(),
label: "PERSON".into(),
start: 0,
end: 5,
}],
let resp = SpacyResponse {
entities: vec![
crate::openhuman::runtime_python_server::spacy::SpacyEntity {
text: "Alice".into(),
label: "PERSON".into(),
start: 0,
end: 5,
},
],
nouns: vec!["migration".into()],
id: Some("0".into()),
error: None,
};
let extracted = spacy_to_extracted(&resp);
let canon = canonicalise(&extracted);
-241
View File
@@ -1,241 +0,0 @@
//! One-time provisioning of spaCy into a dedicated virtualenv under the
//! managed Python runtime.
//!
//! The deterministic retriever needs spaCy + a small English model to extract
//! entities from a query. The managed CPython distribution
//! (`runtime_python`) ships bare, so the first call here:
//! 1. resolves a Python ≥ 3.12 via [`PythonBootstrap`],
//! 2. creates an isolated venv (so we never mutate system/site packages),
//! 3. `pip install`s spaCy and downloads `en_core_web_sm`,
//! 4. writes a marker file so subsequent launches skip straight to spawning.
//!
//! All of this is network + filesystem heavy but happens at most once per host
//! (guarded by the marker). Any failure propagates as an error so the caller
//! (`nlp::extract_query_entities`) can fall back to the in-Rust extractor.
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{bail, Context, Result};
use tokio::process::Command;
use crate::openhuman::config::Config;
use crate::openhuman::runtime_python::PythonBootstrap;
/// Embedded stdio service script, written to disk at provision time so the
/// Python interpreter has a real path to execute.
const SERVICE_PY: &str = include_str!("service.py");
/// Model spaCy downloads / loads. Small English pipeline — fast to load, ~12MB.
pub const SPACY_MODEL: &str = "en_core_web_sm";
/// Timeouts for the one-time install steps. spaCy + model is a few hundred MB
/// of wheels; give pip room on a cold cache / slow link.
const VENV_TIMEOUT: Duration = Duration::from_secs(120);
const PIP_TIMEOUT: Duration = Duration::from_secs(600);
/// A provisioned spaCy runtime: the venv interpreter plus the service script.
#[derive(Debug, Clone)]
pub struct SpacyRuntime {
/// Python executable inside the dedicated venv (has spaCy + model).
pub python_bin: PathBuf,
/// Path to the written `service.py` stdio server.
pub service_script: PathBuf,
}
/// Ensure spaCy + the model are installed and return a ready-to-spawn runtime.
///
/// Idempotent across calls: once the marker file exists we skip venv creation
/// and pip entirely. Errors here are non-fatal to retrieval — the caller falls
/// back to the regex extractor.
pub async fn ensure_spacy(config: &Config) -> Result<SpacyRuntime> {
if !config.runtime_python.enabled {
bail!("runtime_python disabled — cannot provision spaCy");
}
let root = nlp_cache_root(config);
tokio::fs::create_dir_all(&root)
.await
.with_context(|| format!("creating nlp cache dir {}", root.display()))?;
let venv_dir = root.join("spacy-venv");
let marker = venv_dir.join(".openhuman-spacy-ready");
let service_script = root.join("service.py");
// Always (re)write the service script so an upgraded binary ships the
// latest protocol. Cheap (~3KB) and keeps the on-disk copy authoritative.
tokio::fs::write(&service_script, SERVICE_PY)
.await
.with_context(|| format!("writing spaCy service script {}", service_script.display()))?;
let venv_python = venv_python_path(&venv_dir);
if marker.exists() && venv_python.exists() {
log::debug!(
"[memory_tree::nlp] spaCy already provisioned at {}",
venv_dir.display()
);
return Ok(SpacyRuntime {
python_bin: venv_python,
service_script,
});
}
log::info!(
"[memory_tree::nlp] provisioning spaCy (one-time): venv={} model={}",
venv_dir.display(),
SPACY_MODEL
);
// 1. Resolve a base Python interpreter (managed download or system).
let bootstrap = PythonBootstrap::new(config.runtime_python.clone());
let base = bootstrap
.resolve()
.await
.context("resolving base python for spaCy venv")?;
log::debug!(
"[memory_tree::nlp] base python resolved version={} bin={}",
base.version,
base.python_bin.display()
);
// 2. Create the venv (idempotent — venv is safe to re-run).
run_step(
&base.python_bin,
&["-m", "venv", &venv_dir.to_string_lossy()],
VENV_TIMEOUT,
"create venv",
)
.await?;
if !venv_python.exists() {
bail!(
"venv created but interpreter missing at {}",
venv_python.display()
);
}
// 3. Upgrade pip + install spaCy.
run_step(
&venv_python,
&["-m", "pip", "install", "--upgrade", "pip", "spacy"],
PIP_TIMEOUT,
"pip install spacy",
)
.await?;
// 4. Download the model into the venv.
run_step(
&venv_python,
&["-m", "spacy", "download", SPACY_MODEL],
PIP_TIMEOUT,
"spacy download model",
)
.await?;
// 5. Marker — provisioning complete.
tokio::fs::write(&marker, base.version.as_bytes())
.await
.with_context(|| format!("writing spaCy ready marker {}", marker.display()))?;
log::info!("[memory_tree::nlp] spaCy provisioning complete");
Ok(SpacyRuntime {
python_bin: venv_python,
service_script,
})
}
/// Run one provisioning subprocess, capturing output and surfacing a useful
/// error on non-zero exit or timeout.
async fn run_step(python_bin: &Path, args: &[&str], timeout: Duration, label: &str) -> Result<()> {
log::debug!(
"[memory_tree::nlp] step `{label}`: {} {:?}",
python_bin.display(),
args
);
let mut cmd = Command::new(python_bin);
cmd.args(args);
cmd.kill_on_drop(true);
let output = match tokio::time::timeout(timeout, cmd.output()).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => return Err(e).with_context(|| format!("spawning step `{label}`")),
Err(_) => bail!("step `{label}` timed out after {:?}", timeout),
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Tail only — pip output is verbose and may include paths but no
// secrets; cap to keep logs and the error bounded.
let tail: String = stderr
.chars()
.rev()
.take(800)
.collect::<String>()
.chars()
.rev()
.collect();
bail!("step `{label}` failed (status {}): {tail}", output.status);
}
Ok(())
}
/// Cheap, network-free probe: is spaCy already provisioned on this host?
///
/// Mirrors the early-return guard in [`ensure_spacy`] (marker file + venv
/// interpreter present) without creating directories, writing the service
/// script, or resolving Python. Used by the harness-init orchestrator to mark
/// the spaCy step `Done` instantly on a warm host.
pub fn spacy_provisioned(config: &Config) -> bool {
let venv_dir = nlp_cache_root(config).join("spacy-venv");
let marker = venv_dir.join(".openhuman-spacy-ready");
marker.exists() && venv_python_path(&venv_dir).exists()
}
/// Resolve the venv's python executable across platforms.
fn venv_python_path(venv_dir: &Path) -> PathBuf {
if cfg!(windows) {
venv_dir.join("Scripts").join("python.exe")
} else {
venv_dir.join("bin").join("python")
}
}
/// Cache root for NLP artefacts. Honours `runtime_python.cache_dir` when set
/// (keeps all Python state together), else the user cache dir, else a
/// workspace-relative fallback.
fn nlp_cache_root(config: &Config) -> PathBuf {
let configured = config.runtime_python.cache_dir.trim();
if !configured.is_empty() {
return PathBuf::from(configured).join("memory-nlp");
}
if let Some(user_cache) = dirs::cache_dir() {
return user_cache.join("openhuman").join("memory-nlp");
}
config.workspace_dir.join("memory_tree").join("nlp")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn venv_python_path_is_platform_specific() {
let p = venv_python_path(Path::new("/tmp/venv"));
if cfg!(windows) {
assert!(p.ends_with("Scripts/python.exe") || p.ends_with("Scripts\\python.exe"));
} else {
assert_eq!(p, PathBuf::from("/tmp/venv/bin/python"));
}
}
#[test]
fn cache_root_honours_configured_dir() {
let mut cfg = Config::default();
cfg.runtime_python.cache_dir = "/custom/py".to_string();
assert_eq!(
nlp_cache_root(&cfg),
PathBuf::from("/custom/py").join("memory-nlp")
);
}
}
-108
View File
@@ -1,108 +0,0 @@
#!/usr/bin/env python3
"""spaCy NER stdio service for the OpenHuman memory retriever.
Long-lived line-oriented JSON protocol over stdin/stdout. The model is loaded
exactly once at startup, then the process answers extraction requests until its
stdin is closed (the Rust parent drops the child on shutdown via kill_on_drop).
Protocol
--------
On startup, after the model loads, emit exactly one line:
{"ready": true, "model": "en_core_web_sm"}
or, on fatal load failure:
{"ready": false, "error": "<message>"} (then exit non-zero)
For each request line `{"id": <str>, "text": <str>}`, reply with one line:
{"id": <str>,
"entities": [{"text": str, "label": str, "start": int, "end": int}, ...],
"nouns": [str, ...]}
Entities are spaCy named entities (doc.ents). `nouns` are deduplicated lower-
case lemmas of common/proper nouns — E2GraphRAG keys retrieval on both named
entities and salient nouns, so a query like "migration runbook" still yields
graph anchors even with no PERSON/ORG spans.
All output is a single compact JSON object per line, flushed immediately
(the parent runs us with `python -u`, but we flush defensively anyway).
"""
import json
import sys
MODEL_NAME = "en_core_web_sm"
# Disable the parser/lemmatizer pipes we do not need for speed; keep the
# tagger (POS, needed for noun selection) and ner. `lemma` falls back to the
# surface form when the lemmatizer is absent, which is fine for our keys.
_DISABLE = ["parser"]
def _emit(obj):
sys.stdout.write(json.dumps(obj, ensure_ascii=False))
sys.stdout.write("\n")
sys.stdout.flush()
def _load():
import spacy
try:
return spacy.load(MODEL_NAME, disable=_DISABLE)
except Exception:
# Fall back to loading with the full pipeline if the disable list is
# incompatible with the installed model build.
return spacy.load(MODEL_NAME)
def _extract(nlp, text):
doc = nlp(text)
entities = [
{
"text": ent.text,
"label": ent.label_,
"start": int(ent.start_char),
"end": int(ent.end_char),
}
for ent in doc.ents
]
seen = set()
nouns = []
for tok in doc:
if tok.pos_ in ("NOUN", "PROPN") and not tok.is_stop and tok.is_alpha:
key = (tok.lemma_ or tok.text).lower().strip()
if len(key) >= 2 and key not in seen:
seen.add(key)
nouns.append(key)
return entities, nouns
def main():
try:
nlp = _load()
except Exception as exc: # pragma: no cover - exercised only without spaCy
_emit({"ready": False, "error": f"{type(exc).__name__}: {exc}"})
return 1
_emit({"ready": True, "model": MODEL_NAME})
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except Exception as exc:
_emit({"id": None, "error": f"bad request json: {exc}"})
continue
req_id = req.get("id")
text = req.get("text") or ""
try:
entities, nouns = _extract(nlp, text)
_emit({"id": req_id, "entities": entities, "nouns": nouns})
except Exception as exc: # pragma: no cover - defensive
_emit({"id": req_id, "error": f"{type(exc).__name__}: {exc}"})
return 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -96,6 +96,7 @@ pub mod referral;
pub mod routing;
pub mod runtime_node;
pub mod runtime_python;
pub mod runtime_python_server;
pub mod sandbox;
pub mod scheduler_gate;
pub mod screen_intelligence;
@@ -0,0 +1,18 @@
//! Long-running Python backend host.
//!
//! `runtime_python` owns interpreter resolution. This module owns the
//! process-level server that keeps Python-backed model modules warm and serves
//! Rust callers over a private JSONL stdio protocol.
pub mod protocol;
pub mod registry;
pub mod server;
pub mod spacy;
pub mod types;
pub use registry::{enabled_backends, RuntimePythonBackend};
pub use server::{ensure_started, status, RuntimePythonServer};
pub use spacy::{
ensure_spacy, extract as extract_spacy, spacy_provisioned, SpacyResponse, SPACY_MODEL,
};
pub use types::{BackendStatus, RuntimePythonServerStatus};
@@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadyLine {
#[serde(default)]
pub ready: bool,
#[serde(default)]
pub protocol: Option<u32>,
#[serde(default)]
pub backends: Vec<String>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PythonServerRequest {
pub id: String,
pub method: String,
#[serde(default)]
pub params: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PythonServerError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PythonServerResponse {
pub id: Option<String>,
#[serde(default)]
pub ok: bool,
#[serde(default)]
pub result: Option<Value>,
#[serde(default)]
pub error: Option<PythonServerError>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ready_line_parses() {
let ready: ReadyLine =
serde_json::from_str(r#"{"ready":true,"protocol":1,"backends":["spacy"]}"#).unwrap();
assert!(ready.ready);
assert_eq!(ready.protocol, Some(PROTOCOL_VERSION));
assert_eq!(ready.backends, vec!["spacy"]);
}
#[test]
fn response_parses_error_envelope() {
let response: PythonServerResponse = serde_json::from_str(
r#"{"id":"7","ok":false,"error":{"code":"bad_request","message":"missing text"}}"#,
)
.unwrap();
assert!(!response.ok);
assert_eq!(response.id.as_deref(), Some("7"));
assert_eq!(response.error.unwrap().code, "bad_request");
}
}
@@ -0,0 +1,46 @@
use crate::openhuman::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimePythonBackend {
Spacy,
}
impl RuntimePythonBackend {
pub fn id(self) -> &'static str {
match self {
Self::Spacy => "spacy",
}
}
}
pub fn enabled_backends(config: &Config) -> Vec<RuntimePythonBackend> {
if !config.runtime_python.enabled {
return Vec::new();
}
let mut backends = Vec::new();
if config.memory_tree.spacy_enabled {
backends.push(RuntimePythonBackend::Spacy);
}
backends
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_respects_runtime_and_spacy_flags() {
let mut config = Config::default();
config.runtime_python.enabled = false;
config.memory_tree.spacy_enabled = true;
assert!(enabled_backends(&config).is_empty());
config.runtime_python.enabled = true;
config.memory_tree.spacy_enabled = false;
assert!(enabled_backends(&config).is_empty());
config.memory_tree.spacy_enabled = true;
assert_eq!(enabled_backends(&config), vec![RuntimePythonBackend::Spacy]);
}
}
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""OpenHuman runtime Python server.
Private JSONL stdio protocol. Rust owns the process and sends one compact JSON
request per line. This server keeps expensive Python backends warm for the
life of the Rust core process.
"""
import json
import sys
PROTOCOL = 1
SPACY_MODEL = "en_core_web_sm"
_spacy_nlp = None
def _emit(obj):
sys.stdout.write(json.dumps(obj, ensure_ascii=False, separators=(",", ":")))
sys.stdout.write("\n")
sys.stdout.flush()
def _error(req_id, code, message):
return {"id": req_id, "ok": False, "error": {"code": code, "message": str(message)}}
def _configure_stdio():
if hasattr(sys.stdin, "reconfigure"):
sys.stdin.reconfigure(encoding="utf-8")
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
def _load_spacy():
global _spacy_nlp
if _spacy_nlp is not None:
return _spacy_nlp
import spacy
try:
_spacy_nlp = spacy.load(SPACY_MODEL, disable=["parser"])
except Exception:
_spacy_nlp = spacy.load(SPACY_MODEL)
return _spacy_nlp
def _spacy_extract(params):
text = (params or {}).get("text") or ""
nlp = _load_spacy()
doc = nlp(text)
entities = [
{
"text": ent.text,
"label": ent.label_,
"start": int(ent.start_char),
"end": int(ent.end_char),
}
for ent in doc.ents
]
seen = set()
nouns = []
for tok in doc:
if tok.pos_ in ("NOUN", "PROPN") and not tok.is_stop and tok.is_alpha:
key = (tok.lemma_ or tok.text).lower().strip()
if len(key) >= 2 and key not in seen:
seen.add(key)
nouns.append(key)
return {"entities": entities, "nouns": nouns}
def _handle(req):
req_id = req.get("id")
method = req.get("method")
params = req.get("params") or {}
if method == "spacy.extract":
return {"id": req_id, "ok": True, "result": _spacy_extract(params)}
return _error(req_id, "unknown_method", f"unknown runtime_python_server method: {method}")
def main():
_configure_stdio()
try:
_load_spacy()
except Exception as exc:
_emit({"ready": False, "error": f"{type(exc).__name__}: {exc}"})
return 1
_emit({"ready": True, "protocol": PROTOCOL, "backends": ["spacy"]})
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except Exception as exc:
_emit(_error(None, "bad_json", exc))
continue
if not isinstance(req, dict):
_emit(_error(None, "bad_request", "request must be a JSON object"))
continue
try:
_emit(_handle(req))
except Exception as exc:
_emit(_error(req.get("id"), type(exc).__name__, exc))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,453 @@
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use serde::de::DeserializeOwned;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout};
use tokio::sync::Mutex;
use super::protocol::{PythonServerRequest, PythonServerResponse, ReadyLine, PROTOCOL_VERSION};
use super::registry::{enabled_backends, RuntimePythonBackend};
use super::types::{BackendStatus, RuntimePythonServerStatus};
use crate::openhuman::config::Config;
use crate::openhuman::runtime_python::process::PythonLaunchSpec;
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const START_FAILURE_BACKOFF: Duration = Duration::from_secs(300);
static SERVER: OnceLock<Mutex<ServerCache>> = OnceLock::new();
fn server_slot() -> &'static Mutex<ServerCache> {
SERVER.get_or_init(|| Mutex::new(ServerCache::Empty))
}
#[derive(Clone)]
enum ServerCache {
Empty,
Ready(Arc<RuntimePythonServer>),
Failed {
message: String,
retry_after: Instant,
},
}
#[derive(Debug, Clone)]
struct ServerLaunch {
python_bin: PathBuf,
script_path: PathBuf,
backends: Vec<RuntimePythonBackend>,
}
struct ServerInner {
_child: Child,
stdin: ChildStdin,
stdout: Lines<BufReader<ChildStdout>>,
next_id: u64,
ready_backends: Vec<String>,
}
fn drain_server_stderr(stderr: ChildStderr) {
tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
let mut buf = Vec::with_capacity(1024);
let mut line_count = 0u64;
let mut byte_count = 0u64;
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf).await {
Ok(0) => {
log::debug!(
"[runtime_python_server] stderr drain closed lines={} bytes={}",
line_count,
byte_count
);
break;
}
Ok(n) => {
line_count += 1;
byte_count += n as u64;
log::trace!(
"[runtime_python_server] drained stderr line bytes={} total_lines={} total_bytes={}",
n,
line_count,
byte_count
);
}
Err(error) => {
log::debug!(
"[runtime_python_server] stderr drain failed after lines={} bytes={}: {error}",
line_count,
byte_count
);
break;
}
}
}
});
}
pub struct RuntimePythonServer {
launch: ServerLaunch,
inner: Mutex<Option<ServerInner>>,
}
impl RuntimePythonServer {
async fn new(config: &Config) -> Result<Self> {
let launch = prepare_launch(config).await?;
Ok(Self {
launch,
inner: Mutex::new(None),
})
}
pub async fn start(&self) -> Result<()> {
let mut guard = self.inner.lock().await;
if guard.is_some() {
return Ok(());
}
let inner = spawn_inner(&self.launch).await?;
*guard = Some(inner);
Ok(())
}
pub async fn request<T>(&self, method: &str, params: Value) -> Result<T>
where
T: DeserializeOwned,
{
match self.request_once(method, params.clone()).await {
Ok(value) => Ok(value),
Err(err) => {
log::warn!(
"[runtime_python_server] request failed; restarting server before retry: {err:#}"
);
self.reset().await;
self.request_once(method, params).await
}
}
}
async fn request_once<T>(&self, method: &str, params: Value) -> Result<T>
where
T: DeserializeOwned,
{
let mut guard = self.inner.lock().await;
if guard.is_none() {
*guard = Some(spawn_inner(&self.launch).await?);
}
let inner = guard.as_mut().context("runtime python server missing")?;
let id = inner.next_id.to_string();
inner.next_id += 1;
let request = PythonServerRequest {
id: id.clone(),
method: method.to_string(),
params,
};
let mut line = serde_json::to_string(&request)?;
line.push('\n');
log::debug!(
"[runtime_python_server] sending request id={} method={}",
id,
method
);
inner
.stdin
.write_all(line.as_bytes())
.await
.context("writing runtime python server request")?;
inner
.stdin
.flush()
.await
.context("flushing runtime python server request")?;
loop {
let next = tokio::time::timeout(REQUEST_TIMEOUT, inner.stdout.next_line()).await;
let line = match next {
Ok(Ok(Some(line))) => line,
Ok(Ok(None)) => bail!("runtime python server closed stdout"),
Ok(Err(error)) => {
return Err(error).context("reading runtime python server response")
}
Err(_) => bail!("runtime python server request timed out"),
};
let response: PythonServerResponse = match serde_json::from_str(&line) {
Ok(response) => response,
Err(error) => {
log::warn!(
"[runtime_python_server] unparseable response skipped: {error}; line_len={}",
line.len()
);
continue;
}
};
if response.id.as_deref() != Some(id.as_str()) {
log::debug!(
"[runtime_python_server] skipped response for different id={:?}",
response.id
);
continue;
}
if !response.ok {
let message = response
.error
.map(|error| format!("{}: {}", error.code, error.message))
.unwrap_or_else(|| "unknown python server error".to_string());
bail!("runtime python server `{method}` failed: {message}");
}
let result = response.result.unwrap_or(Value::Null);
return serde_json::from_value(result)
.with_context(|| format!("decoding runtime python server `{method}` result"));
}
}
async fn reset(&self) {
let mut guard = self.inner.lock().await;
*guard = None;
}
fn status_from_inner(&self, inner: Option<&ServerInner>) -> RuntimePythonServerStatus {
let running = inner.is_some();
let ready_backends = inner
.map(|inner| inner.ready_backends.as_slice())
.unwrap_or(&[]);
RuntimePythonServerStatus {
enabled: true,
running,
backends: self
.launch
.backends
.iter()
.map(|backend| BackendStatus {
id: backend.id().to_string(),
enabled: true,
ready: ready_backends.iter().any(|id| id == backend.id()),
message: None,
})
.collect(),
message: None,
}
}
pub async fn status(&self) -> RuntimePythonServerStatus {
let guard = self.inner.lock().await;
self.status_from_inner(guard.as_ref())
}
}
pub async fn ensure_started(config: &Config) -> Result<Arc<RuntimePythonServer>> {
let mut guard = server_slot().lock().await;
match &*guard {
ServerCache::Ready(existing) => {
let existing = existing.clone();
if let Err(error) = existing.start().await {
let message = format!("{error:#}");
log::warn!(
"[runtime_python_server] cached server failed to start; backing off: {message}"
);
*guard = ServerCache::Failed {
message: message.clone(),
retry_after: Instant::now() + START_FAILURE_BACKOFF,
};
bail!("runtime python server unavailable: {message}");
}
return Ok(existing);
}
ServerCache::Failed {
message,
retry_after,
} if Instant::now() < *retry_after => {
bail!("runtime python server unavailable after previous startup failure: {message}");
}
ServerCache::Failed { .. } | ServerCache::Empty => {}
}
match start_new_server(config).await {
Ok(server) => {
*guard = ServerCache::Ready(server.clone());
Ok(server)
}
Err(error) => {
let message = format!("{error:#}");
log::warn!(
"[runtime_python_server] startup failed; caching fallback state for {:?}: {message}",
START_FAILURE_BACKOFF
);
*guard = ServerCache::Failed {
message: message.clone(),
retry_after: Instant::now() + START_FAILURE_BACKOFF,
};
bail!("runtime python server unavailable: {message}");
}
}
}
async fn start_new_server(config: &Config) -> Result<Arc<RuntimePythonServer>> {
let server = Arc::new(RuntimePythonServer::new(config).await?);
server.start().await?;
Ok(server)
}
pub async fn status() -> RuntimePythonServerStatus {
let cached = {
let guard = server_slot().lock().await;
guard.clone()
};
match cached {
ServerCache::Ready(server) => server.status().await,
ServerCache::Failed { message, .. } => RuntimePythonServerStatus {
enabled: true,
running: false,
backends: Vec::new(),
message: Some(format!("runtime python server unavailable: {message}")),
},
ServerCache::Empty => {
RuntimePythonServerStatus::disabled("runtime python server has not started")
}
}
}
async fn prepare_launch(config: &Config) -> Result<ServerLaunch> {
let backends = enabled_backends(config);
if backends.is_empty() {
bail!("no runtime python server backends enabled");
}
let spacy_runtime = if backends.contains(&RuntimePythonBackend::Spacy) {
Some(super::spacy::ensure_spacy(config).await?)
} else {
None
};
let python_bin = if let Some(spacy_runtime) = spacy_runtime {
spacy_runtime.python_bin
} else {
crate::openhuman::runtime_python::PythonBootstrap::new(config.runtime_python.clone())
.resolve()
.await?
.python_bin
};
let script_path = write_server_script(config).await?;
Ok(ServerLaunch {
python_bin,
script_path,
backends,
})
}
async fn write_server_script(config: &Config) -> Result<PathBuf> {
let root = super::spacy::python_server_cache_root(config);
tokio::fs::create_dir_all(&root)
.await
.with_context(|| format!("creating runtime python server cache {}", root.display()))?;
let script_path = root.join("runtime_python_server.py");
tokio::fs::write(&script_path, include_str!("server.py"))
.await
.with_context(|| {
format!(
"writing runtime python server script {}",
script_path.display()
)
})?;
Ok(script_path)
}
async fn spawn_inner(launch: &ServerLaunch) -> Result<ServerInner> {
log::info!(
"[runtime_python_server] starting server python={} script={} backends={:?}",
launch.python_bin.display(),
launch.script_path.display(),
launch.backends
);
let resolved = crate::openhuman::runtime_python::ResolvedPython {
bin_dir: launch
.python_bin
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(".")),
python_bin: launch.python_bin.clone(),
version: "runtime-backend".to_string(),
source: crate::openhuman::runtime_python::PythonSource::Managed,
};
let spec = PythonLaunchSpec::new(launch.script_path.clone());
let mut child =
crate::openhuman::runtime_python::process::spawn_stdio_process(&resolved, &spec)
.context("spawning runtime python server")?;
let stdin = child
.stdin
.take()
.context("runtime python server stdin missing")?;
let stdout = child
.stdout
.take()
.context("runtime python server stdout missing")?;
if let Some(stderr) = child.stderr.take() {
drain_server_stderr(stderr);
} else {
log::debug!("[runtime_python_server] stderr pipe missing; continuing without drain");
}
let mut lines = BufReader::new(stdout).lines();
let ready_line = match tokio::time::timeout(HANDSHAKE_TIMEOUT, lines.next_line()).await {
Ok(Ok(Some(line))) => line,
Ok(Ok(None)) => bail!("runtime python server exited before readiness handshake"),
Ok(Err(error)) => return Err(error).context("reading runtime python server handshake"),
Err(_) => bail!("runtime python server readiness handshake timed out"),
};
let ready: ReadyLine = serde_json::from_str(&ready_line)
.with_context(|| format!("parsing runtime python server ready line: {ready_line}"))?;
if !ready.ready {
bail!(
"runtime python server failed to start: {}",
ready.error.unwrap_or_else(|| "unknown".to_string())
);
}
if ready.protocol != Some(PROTOCOL_VERSION) {
bail!(
"runtime python server protocol mismatch: expected {}, got {:?}",
PROTOCOL_VERSION,
ready.protocol
);
}
log::info!(
"[runtime_python_server] server ready backends={:?}",
ready.backends
);
Ok(ServerInner {
_child: child,
stdin,
stdout: lines,
next_id: 0,
ready_backends: ready.backends,
})
}
pub async fn request_spacy_extract(
config: &Config,
text: &str,
) -> Result<super::spacy::SpacyResponse> {
let server = ensure_started(config).await?;
server
.request("spacy.extract", json!({ "text": text }))
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn prepare_launch_rejects_disabled_backends() {
let mut config = Config::default();
config.runtime_python.enabled = false;
let err = prepare_launch(&config).await.unwrap_err().to_string();
assert!(err.contains("no runtime python server backends enabled"));
}
}
@@ -0,0 +1,306 @@
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tokio::sync::Mutex;
use crate::openhuman::config::Config;
use crate::openhuman::runtime_python::PythonBootstrap;
pub const SPACY_MODEL: &str = "en_core_web_sm";
const VENV_TIMEOUT: Duration = Duration::from_secs(120);
const PIP_TIMEOUT: Duration = Duration::from_secs(600);
static SPACY_PROVISION_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn spacy_provision_lock() -> &'static Mutex<()> {
SPACY_PROVISION_LOCK.get_or_init(|| Mutex::new(()))
}
#[derive(Debug, Clone)]
pub struct SpacyRuntime {
pub python_bin: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpacyEntity {
pub text: String,
pub label: String,
#[serde(default)]
pub start: u32,
#[serde(default)]
pub end: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpacyResponse {
#[serde(default)]
pub entities: Vec<SpacyEntity>,
#[serde(default)]
pub nouns: Vec<String>,
}
pub async fn extract(config: &Config, text: &str) -> Result<SpacyResponse> {
super::server::request_spacy_extract(config, text).await
}
pub async fn ensure_spacy(config: &Config) -> Result<SpacyRuntime> {
let _guard = spacy_provision_lock().lock().await;
if !config.runtime_python.enabled {
bail!("runtime_python disabled — cannot provision spaCy");
}
let root = python_server_cache_root(config);
tokio::fs::create_dir_all(&root).await.with_context(|| {
format!(
"creating runtime python server cache dir {}",
root.display()
)
})?;
let venv_dir = runtime_spacy_venv_dir(config);
let venv_python = venv_python_path(&venv_dir);
if spacy_venv_ready(&venv_dir) {
log::debug!(
"[runtime_python_server::spacy] spaCy already provisioned at {}",
venv_dir.display()
);
return Ok(SpacyRuntime {
python_bin: venv_python,
});
}
if let Some(existing_venv) = migrate_or_reuse_legacy_spacy_venv(config, &venv_dir).await? {
return Ok(SpacyRuntime {
python_bin: venv_python_path(&existing_venv),
});
}
log::info!(
"[runtime_python_server::spacy] provisioning spaCy venv={} model={}",
venv_dir.display(),
SPACY_MODEL
);
let bootstrap = PythonBootstrap::new(config.runtime_python.clone());
let base = bootstrap
.resolve()
.await
.context("resolving base python for runtime python server spaCy venv")?;
log::debug!(
"[runtime_python_server::spacy] base python resolved version={} bin={}",
base.version,
base.python_bin.display()
);
run_step(
&base.python_bin,
&["-m", "venv", &venv_dir.to_string_lossy()],
VENV_TIMEOUT,
"create venv",
)
.await?;
if !venv_python.exists() {
bail!(
"venv created but interpreter missing at {}",
venv_python.display()
);
}
run_step(
&venv_python,
&["-m", "pip", "install", "--upgrade", "pip", "spacy"],
PIP_TIMEOUT,
"pip install spacy",
)
.await?;
run_step(
&venv_python,
&["-m", "spacy", "download", SPACY_MODEL],
PIP_TIMEOUT,
"spacy download model",
)
.await?;
let marker = venv_dir.join(".openhuman-spacy-ready");
tokio::fs::write(&marker, base.version.as_bytes())
.await
.with_context(|| format!("writing spaCy ready marker {}", marker.display()))?;
log::info!("[runtime_python_server::spacy] spaCy provisioning complete");
Ok(SpacyRuntime {
python_bin: venv_python,
})
}
async fn run_step(python_bin: &Path, args: &[&str], timeout: Duration, label: &str) -> Result<()> {
log::debug!(
"[runtime_python_server::spacy] step `{label}`: {} {:?}",
python_bin.display(),
args
);
let mut cmd = Command::new(python_bin);
cmd.args(args);
cmd.kill_on_drop(true);
let output = match tokio::time::timeout(timeout, cmd.output()).await {
Ok(Ok(output)) => output,
Ok(Err(error)) => return Err(error).with_context(|| format!("spawning step `{label}`")),
Err(_) => bail!("step `{label}` timed out after {:?}", timeout),
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let tail: String = stderr
.chars()
.rev()
.take(800)
.collect::<String>()
.chars()
.rev()
.collect();
bail!("step `{label}` failed (status {}): {tail}", output.status);
}
Ok(())
}
pub fn spacy_provisioned(config: &Config) -> bool {
spacy_venv_ready(&runtime_spacy_venv_dir(config))
|| legacy_spacy_venv_dirs(config)
.into_iter()
.any(|venv_dir| spacy_venv_ready(&venv_dir))
}
pub(crate) fn python_server_cache_root(config: &Config) -> PathBuf {
let configured = config.runtime_python.cache_dir.trim();
if !configured.is_empty() {
return PathBuf::from(configured).join("runtime-python-server");
}
if let Some(user_cache) = dirs::cache_dir() {
return user_cache.join("openhuman").join("runtime-python-server");
}
config.workspace_dir.join("runtime_python_server")
}
fn runtime_spacy_venv_dir(config: &Config) -> PathBuf {
python_server_cache_root(config).join("spacy-venv")
}
async fn migrate_or_reuse_legacy_spacy_venv(
config: &Config,
target_venv: &Path,
) -> Result<Option<PathBuf>> {
for legacy_venv in legacy_spacy_venv_dirs(config) {
if legacy_venv == target_venv || !spacy_venv_ready(&legacy_venv) {
continue;
}
if !target_venv.exists() {
if let Some(parent) = target_venv.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("creating spaCy venv parent {}", parent.display()))?;
}
match tokio::fs::rename(&legacy_venv, target_venv).await {
Ok(()) => {
log::info!(
"[runtime_python_server::spacy] migrated legacy spaCy venv {} -> {}",
legacy_venv.display(),
target_venv.display()
);
return Ok(Some(target_venv.to_path_buf()));
}
Err(error) => {
log::warn!(
"[runtime_python_server::spacy] could not migrate legacy spaCy venv {} -> {}; reusing legacy path: {error}",
legacy_venv.display(),
target_venv.display()
);
return Ok(Some(legacy_venv));
}
}
}
log::info!(
"[runtime_python_server::spacy] reusing legacy spaCy venv {} because target {} is not ready",
legacy_venv.display(),
target_venv.display()
);
return Ok(Some(legacy_venv));
}
Ok(None)
}
fn legacy_spacy_venv_dirs(config: &Config) -> Vec<PathBuf> {
let mut roots = Vec::new();
let configured = config.runtime_python.cache_dir.trim();
if !configured.is_empty() {
roots.push(PathBuf::from(configured).join("memory-nlp"));
} else if let Some(user_cache) = dirs::cache_dir() {
roots.push(user_cache.join("openhuman").join("memory-nlp"));
}
roots.push(config.workspace_dir.join("memory_tree").join("nlp"));
roots
.into_iter()
.map(|root| root.join("spacy-venv"))
.collect()
}
fn spacy_venv_ready(venv_dir: &Path) -> bool {
venv_dir.join(".openhuman-spacy-ready").exists() && venv_python_path(venv_dir).exists()
}
fn venv_python_path(venv_dir: &Path) -> PathBuf {
if cfg!(windows) {
venv_dir.join("Scripts").join("python.exe")
} else {
venv_dir.join("bin").join("python")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_root_honours_runtime_python_cache_dir() {
let mut config = Config::default();
config.runtime_python.cache_dir = "/tmp/openhuman-python".to_string();
assert_eq!(
python_server_cache_root(&config),
PathBuf::from("/tmp/openhuman-python").join("runtime-python-server")
);
}
#[test]
fn legacy_configured_cache_is_considered_provisioned() {
let temp = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.runtime_python.cache_dir = temp.path().to_string_lossy().to_string();
let legacy_venv = temp.path().join("memory-nlp").join("spacy-venv");
std::fs::create_dir_all(legacy_venv.join(if cfg!(windows) { "Scripts" } else { "bin" }))
.unwrap();
std::fs::write(legacy_venv.join(".openhuman-spacy-ready"), "test").unwrap();
std::fs::write(venv_python_path(&legacy_venv), "").unwrap();
assert!(spacy_provisioned(&config));
}
#[test]
fn spacy_response_parses() {
let response: SpacyResponse = serde_json::from_str(
r#"{"entities":[{"text":"Alice","label":"PERSON","start":0,"end":5}],"nouns":["migration"]}"#,
)
.unwrap();
assert_eq!(response.entities[0].label, "PERSON");
assert_eq!(response.nouns, vec!["migration"]);
}
}
@@ -0,0 +1,28 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendStatus {
pub id: String,
pub enabled: bool,
pub ready: bool,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimePythonServerStatus {
pub enabled: bool,
pub running: bool,
pub backends: Vec<BackendStatus>,
pub message: Option<String>,
}
impl RuntimePythonServerStatus {
pub fn disabled(message: impl Into<String>) -> Self {
Self {
enabled: false,
running: false,
backends: Vec::new(),
message: Some(message.into()),
}
}
}