Implement Sophia MVP stages 4-7 (introspection, queries, persistence, polish)
Brings the system from "engrams cluster + form synapses" to a complete end-to-end demo: ingest text, watch it cluster, ask questions, restart with state intact. - Stage 4: birth introspection (taxonomy/goals/open_questions via LLM, bounded by the global parallel-op semaphore), per-engram memory log, click-to-inspect side panel. - Stage 5: queries as conversations. POST /api/galaxy/:id/query embeds the question, materializes a pinned Query-Engram at the donut center, runs broadcast retrieval (global cosine scan + 1-hop synaptic expansion with attenuation) and fans out responder LLM calls. The integrator runs every 2s on accumulated snippets and streams the refining answer back over SSE; responders briefly transition to Conversing on the WS bus so the right dots light up. - Stage 6: snapshot persistence. sled-backed store keyed by galaxy id, JSON-encoded values (bincode chokes on internally-tagged enums like Manifest/MemoryKind), 60s periodic snapshot task, hydrate-on-boot, DELETE /api/galaxy/:id wired through. State survives kill -9. - Stage 7: HUD additions (sim ticks/sec, LLM queue depth, FPS) via a new GET /api/stats polled at 1Hz. `sophia demo` subcommand boots the server then auto-ingests a 50-paragraph corpus baked into the binary with include_str!. README quickstart added. Token caps for query_responder/integrator bumped (gemma-4-e4b is a thinking model — output budget must cover hidden reasoning + visible answer, otherwise content comes back empty). Pinned engrams skip physics; their tick scheduling is also skipped at materialization so they stay perfectly still at the donut center. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,20 @@ impl LmStudioClient {
|
||||
&self.inner.cfg
|
||||
}
|
||||
|
||||
/// How many parallel-op permits are currently free. Equal to `parallel_ops`
|
||||
/// when idle, drops as concurrent chat/embedding calls run. Used by the
|
||||
/// HUD's "LLM queue" indicator — a number that consistently sits at 0
|
||||
/// means the LLM is the bottleneck.
|
||||
pub fn available_permits(&self) -> usize {
|
||||
self.inner.parallel.available_permits()
|
||||
}
|
||||
|
||||
/// The configured ceiling. Same as `config().parallel_ops`, exposed for
|
||||
/// callers that already have the client handle but not the config.
|
||||
pub fn parallel_ops(&self) -> usize {
|
||||
self.inner.cfg.parallel_ops.max(1)
|
||||
}
|
||||
|
||||
/// Apply Bearer auth header if a token is configured. LM Studio's REST
|
||||
/// server returns 401 if auth is enabled (default in 0.3.x+) and no token
|
||||
/// is sent.
|
||||
|
||||
90
crates/sophia-llm/src/introspect.rs
Normal file
90
crates/sophia-llm/src/introspect.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! LLM-driven introspection: ask the chat model to summarise one piece of
|
||||
//! text into a small structured `Introspection` record (taxonomy, goals,
|
||||
//! open questions). Caveman budget per §13.5.
|
||||
|
||||
use serde::Deserialize;
|
||||
use sophia_core::Introspection;
|
||||
|
||||
use crate::budget::TokenCaps;
|
||||
use crate::client::{ChatMessage, LmError, LmStudioClient};
|
||||
|
||||
const SYSTEM_PROMPT: &str = "\
|
||||
You summarize one short piece of knowledge for a knowledge graph.\n\
|
||||
Output ONLY valid JSON matching this schema:\n\
|
||||
{\n \"taxonomy\": [string, ...],\n \"goals\": [string, ...],\n \"open_questions\": [string, ...]\n}\n\
|
||||
- taxonomy: 3 to 5 short topical labels, from general to specific.\n\
|
||||
- goals: 1 to 3 short statements of what this knowledge enables or answers.\n\
|
||||
- open_questions: 1 to 3 short questions this knowledge invites but doesn't answer.\n\
|
||||
Each item must be at most 12 words. No prose outside the JSON.";
|
||||
|
||||
/// Truncate excessively long input so we stay inside the input cap. Caveman
|
||||
/// budget tolerates ~400 input tokens total — content beyond ~1200 chars
|
||||
/// (≈300 tokens) is unlikely to add useful introspection, so we cut.
|
||||
const MAX_INPUT_CHARS: usize = 1200;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IntrospectionPayload {
|
||||
#[serde(default)]
|
||||
taxonomy: Vec<String>,
|
||||
#[serde(default)]
|
||||
goals: Vec<String>,
|
||||
#[serde(default)]
|
||||
open_questions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Introspect a single piece of text. Returns an empty `Introspection` if
|
||||
/// the LLM responds with non-JSON or empty content; the caller can decide
|
||||
/// whether to retry. Caps input length defensively.
|
||||
pub async fn introspect_text(
|
||||
client: &LmStudioClient,
|
||||
text: &str,
|
||||
caps: TokenCaps,
|
||||
) -> Result<Introspection, LmError> {
|
||||
let trimmed = if text.chars().count() > MAX_INPUT_CHARS {
|
||||
let head: String = text.chars().take(MAX_INPUT_CHARS).collect();
|
||||
format!("{head}…")
|
||||
} else {
|
||||
text.to_string()
|
||||
};
|
||||
let messages = [
|
||||
ChatMessage::system(SYSTEM_PROMPT),
|
||||
ChatMessage::user(format!("Content:\n\"\"\"\n{trimmed}\n\"\"\"")),
|
||||
];
|
||||
let raw = client.chat(&messages, caps).await?;
|
||||
Ok(parse_introspection(&raw))
|
||||
}
|
||||
|
||||
/// Best-effort JSON extraction. Models occasionally wrap output in ```json
|
||||
/// fences; we strip those before parsing. On failure returns empty.
|
||||
fn parse_introspection(raw: &str) -> Introspection {
|
||||
let json_text = strip_code_fences(raw.trim());
|
||||
let parsed: Result<IntrospectionPayload, _> = serde_json::from_str(json_text);
|
||||
match parsed {
|
||||
Ok(p) => Introspection {
|
||||
taxonomy: cleanup(p.taxonomy),
|
||||
goals: cleanup(p.goals),
|
||||
open_questions: cleanup(p.open_questions),
|
||||
},
|
||||
Err(_) => Introspection::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_code_fences(s: &str) -> &str {
|
||||
let s = s.trim();
|
||||
let stripped = s.strip_prefix("```json").or_else(|| s.strip_prefix("```"));
|
||||
if let Some(rest) = stripped {
|
||||
let rest = rest.trim_start_matches('\n');
|
||||
rest.strip_suffix("```").unwrap_or(rest).trim()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup(items: Vec<String>) -> Vec<String> {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.take(8) // hard upper bound, just in case
|
||||
.collect()
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
mod budget;
|
||||
mod client;
|
||||
mod introspect;
|
||||
mod query;
|
||||
|
||||
pub use budget::{TokenCaps, TokenCapsAll};
|
||||
pub use client::{ChatMessage, ChatRole, LmError, LmStudioClient, LmStudioConfig, EMBED_BATCH_LIMIT};
|
||||
pub use introspect::introspect_text;
|
||||
pub use query::{integrate_responses, respond_to_query};
|
||||
|
||||
92
crates/sophia-llm/src/query.rs
Normal file
92
crates/sophia-llm/src/query.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! Query-time LLM prompts (Stage 5).
|
||||
//!
|
||||
//! Two distinct calls per query:
|
||||
//!
|
||||
//! - `respond_to_query`: each resonating engram (the "responder") is asked to
|
||||
//! say what *its own* knowledge contributes to the question. Caveman cap
|
||||
//! 200 in / 150 out — these are short, single-perspective snippets.
|
||||
//! - `integrate_responses`: the Query-Engram folds the snippets it has
|
||||
//! received so far into a single running answer. Cap 800 in / 300 out.
|
||||
//! Re-runs every couple of seconds while new snippets arrive, so the
|
||||
//! browser sees the answer refine.
|
||||
|
||||
use crate::budget::TokenCaps;
|
||||
use crate::client::{ChatMessage, LmError, LmStudioClient};
|
||||
|
||||
const RESPONDER_SYSTEM: &str = "\
|
||||
You are one fragment of knowledge inside a larger memory. \
|
||||
A query has been broadcast through the network. \
|
||||
Speak ONLY for what your own content contributes to the question. \
|
||||
Do not summarize the question. Do not speculate beyond your content. \
|
||||
If your content is irrelevant, reply with the single word: PASS. \
|
||||
Otherwise: 1–3 short sentences, plain prose, no headings, no bullets.";
|
||||
|
||||
const INTEGRATOR_SYSTEM: &str = "\
|
||||
You are the answering process for a question. You receive snippets from \
|
||||
fragments of knowledge that resonated with the question. Synthesize them \
|
||||
into ONE coherent answer to the user's question. Cite no sources. \
|
||||
Do not list the snippets. Do not say things like \"based on the snippets\". \
|
||||
Plain prose, 2–6 sentences. If the snippets are insufficient, say so briefly.";
|
||||
|
||||
/// Truncate one responder's source content to keep the per-call payload small.
|
||||
const MAX_RESPONDER_INPUT_CHARS: usize = 600;
|
||||
|
||||
/// Ask one resonating engram for its take on the question. Returns `None` if
|
||||
/// the responder explicitly opts out (`PASS`) or returns empty content.
|
||||
/// Keeping `Option<String>` rather than `Result<...>` lets the orchestrator
|
||||
/// distinguish "this engram had nothing useful to say" from "the LLM call
|
||||
/// failed" — both are fine, but only the latter should be logged.
|
||||
pub async fn respond_to_query(
|
||||
client: &LmStudioClient,
|
||||
question: &str,
|
||||
responder_text: &str,
|
||||
caps: TokenCaps,
|
||||
) -> Result<Option<String>, LmError> {
|
||||
let trimmed = if responder_text.chars().count() > MAX_RESPONDER_INPUT_CHARS {
|
||||
let head: String = responder_text.chars().take(MAX_RESPONDER_INPUT_CHARS).collect();
|
||||
format!("{head}…")
|
||||
} else {
|
||||
responder_text.to_string()
|
||||
};
|
||||
let user = format!(
|
||||
"Question:\n{question}\n\nYour content:\n\"\"\"\n{trimmed}\n\"\"\""
|
||||
);
|
||||
let messages = [
|
||||
ChatMessage::system(RESPONDER_SYSTEM),
|
||||
ChatMessage::user(user),
|
||||
];
|
||||
let raw = client.chat(&messages, caps).await?;
|
||||
let cleaned = raw.trim();
|
||||
if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("PASS") {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(cleaned.to_string()))
|
||||
}
|
||||
|
||||
/// Fold the current set of responder snippets into one running answer. The
|
||||
/// orchestrator calls this repeatedly as more snippets arrive.
|
||||
pub async fn integrate_responses(
|
||||
client: &LmStudioClient,
|
||||
question: &str,
|
||||
snippets: &[String],
|
||||
caps: TokenCaps,
|
||||
) -> Result<String, LmError> {
|
||||
if snippets.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
// Number the snippets so the integrator can reason about distinct
|
||||
// perspectives without us having to add ids.
|
||||
let mut buf = String::new();
|
||||
for (i, s) in snippets.iter().enumerate() {
|
||||
buf.push_str(&format!("[{}] {}\n", i + 1, s.trim()));
|
||||
}
|
||||
let user = format!(
|
||||
"Question:\n{question}\n\nSnippets:\n{buf}\nAnswer the question now."
|
||||
);
|
||||
let messages = [
|
||||
ChatMessage::system(INTEGRATOR_SYSTEM),
|
||||
ChatMessage::user(user),
|
||||
];
|
||||
let raw = client.chat(&messages, caps).await?;
|
||||
Ok(raw.trim().to_string())
|
||||
}
|
||||
Reference in New Issue
Block a user