use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::knowledge::KnowledgeEntry;
use crate::store::WakeCascade;
type HmacSha256 = Hmac<Sha256>;
pub fn create_token(session_id: &str, step: u32) -> String {
let payload = format!("{}.{}", session_id, step);
let key = format!("wake-{}-ritual", session_id);
let mut mac =
HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC can take key of any size");
mac.update(payload.as_bytes());
let signature = BASE64.encode(mac.finalize().into_bytes());
format!("{}.{}", payload, &signature[..16])
}
pub fn verify_token(token: &str) -> Result<(String, u32), String> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid token format".to_string());
}
let session_id = parts[0];
let step: u32 = parts[1]
.parse()
.map_err(|_| "Invalid step in token".to_string())?;
let provided_sig = parts[2];
let payload = format!("{}.{}", session_id, step);
let key = format!("wake-{}-ritual", session_id);
let mut mac =
HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC can take key of any size");
mac.update(payload.as_bytes());
let expected_sig = BASE64.encode(mac.finalize().into_bytes());
if &expected_sig[..16] != provided_sig {
return Err("Invalid token signature".to_string());
}
Ok((session_id.to_string(), step))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhraseSourceTag {
Authored,
Derived,
Auto,
None,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BloomChunkMeta {
pub authored_phrase_count: u16,
pub is_phraseless: bool,
#[serde(default)]
pub remembered_chunks: u32,
#[serde(default)]
pub helped_chunks: u32,
#[serde(default)]
pub skipped_chunks: u32,
#[serde(default)]
pub authored_chunks: u32,
#[serde(default)]
pub derived_chunks: u32,
#[serde(default)]
pub auto_chunks: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WakeSession {
pub session_id: String,
pub bloom_ids: Vec<String>,
pub current_index: usize,
pub current_chunk_index: u16,
pub step: u32,
pub attempts_on_current: u8,
pub remembered_count: u32,
pub needed_help_count: u32,
pub skipped_count: u32,
pub created_at: i64,
pub bloom_chunk_meta: Vec<BloomChunkMeta>,
}
impl WakeSession {
pub fn new(cascade: &WakeCascade) -> Self {
let mut bloom_ids = Vec::new();
let mut bloom_chunk_meta = Vec::new();
for entry in cascade
.core
.iter()
.chain(cascade.recent.iter())
.chain(cascade.bridges.iter())
{
bloom_ids.push(entry.id.clone());
let authored_phrase_count = authored_phrase_count(entry);
bloom_chunk_meta.push(BloomChunkMeta {
authored_phrase_count,
is_phraseless: authored_phrase_count == 0,
..Default::default()
});
}
Self {
session_id: uuid::Uuid::new_v4().to_string(),
bloom_ids,
current_index: 0,
current_chunk_index: 0,
step: 0,
attempts_on_current: 0,
remembered_count: 0,
needed_help_count: 0,
skipped_count: 0,
created_at: chrono::Utc::now().timestamp(),
bloom_chunk_meta,
}
}
pub fn current_bloom_id(&self) -> Option<&str> {
self.bloom_ids.get(self.current_index).map(|s| s.as_str())
}
pub fn current_meta(&self) -> Option<&BloomChunkMeta> {
self.bloom_chunk_meta.get(self.current_index)
}
pub fn total_blooms(&self) -> usize {
self.bloom_ids.len()
}
pub fn current_bloom_position(&self) -> usize {
self.current_index + 1
}
pub fn is_complete(&self) -> bool {
self.current_index >= self.bloom_ids.len()
}
pub fn advance_remembered(&mut self, bloom_total_chunks: u16, phrase_source: PhraseSourceTag) {
debug_assert!(!self.is_complete(), "advance called on completed session");
debug_assert!(
(self.current_chunk_index as usize) < bloom_total_chunks.max(1) as usize,
"current_chunk_index {} >= bloom_total_chunks {}",
self.current_chunk_index,
bloom_total_chunks
);
self.remembered_count += 1;
if let Some(meta) = self.bloom_chunk_meta.get_mut(self.current_index) {
meta.remembered_chunks += 1;
match phrase_source {
PhraseSourceTag::Authored => meta.authored_chunks += 1,
PhraseSourceTag::Derived => meta.derived_chunks += 1,
PhraseSourceTag::Auto => meta.auto_chunks += 1,
PhraseSourceTag::None => {}
}
}
self.step = self.step.saturating_add(1);
self.advance_chunk_or_bloom(bloom_total_chunks);
}
pub fn advance_helped(&mut self, bloom_total_chunks: u16, phrase_source: PhraseSourceTag) {
debug_assert!(!self.is_complete());
self.needed_help_count += 1;
if let Some(meta) = self.bloom_chunk_meta.get_mut(self.current_index) {
meta.helped_chunks += 1;
match phrase_source {
PhraseSourceTag::Authored => meta.authored_chunks += 1,
PhraseSourceTag::Derived => meta.derived_chunks += 1,
PhraseSourceTag::Auto => meta.auto_chunks += 1,
PhraseSourceTag::None => {}
}
}
self.step = self.step.saturating_add(1);
self.advance_chunk_or_bloom(bloom_total_chunks);
}
pub fn advance_skipped(&mut self, bloom_total_chunks: u16) {
debug_assert!(!self.is_complete());
self.skipped_count += 1;
if let Some(meta) = self.bloom_chunk_meta.get_mut(self.current_index) {
meta.skipped_chunks += 1;
}
self.step = self.step.saturating_add(1);
self.advance_chunk_or_bloom(bloom_total_chunks);
}
fn advance_chunk_or_bloom(&mut self, bloom_total_chunks: u16) {
let next_chunk = self.current_chunk_index.saturating_add(1);
if (next_chunk as usize) < bloom_total_chunks.max(1) as usize {
self.current_chunk_index = next_chunk;
self.attempts_on_current = 0;
} else {
self.current_index += 1;
self.current_chunk_index = 0;
self.attempts_on_current = 0;
}
debug_assert!(
self.current_index <= self.bloom_ids.len(),
"current_index {} overshot bloom_ids.len() {}",
self.current_index,
self.bloom_ids.len()
);
}
pub fn clamp_if_chunks_shrank(&mut self, bloom_total_chunks: u16) -> bool {
let total = bloom_total_chunks.max(1) as usize;
if (self.current_chunk_index as usize) >= total {
self.current_index += 1;
self.current_chunk_index = 0;
self.attempts_on_current = 0;
true
} else {
false
}
}
pub fn increment_attempt(&mut self) {
self.attempts_on_current += 1;
}
}
pub fn authored_phrase_count(entry: &KnowledgeEntry) -> u16 {
if !entry.wake_phrases.is_empty() {
u16::try_from(entry.wake_phrases.len()).unwrap_or(u16::MAX)
} else if entry.wake_phrase.is_some() {
1
} else {
0
}
}
pub fn authored_phrase_at(entry: &KnowledgeEntry, idx: usize) -> Option<String> {
if !entry.wake_phrases.is_empty() {
entry.wake_phrases.get(idx).cloned()
} else if idx == 0 {
entry.wake_phrase.clone()
} else {
None
}
}
#[derive(Debug, Serialize)]
pub struct WakeBeginResponse {
pub status: String,
pub session: String,
pub prompt: BloomPrompt,
pub progress: Progress,
}
#[derive(Debug, Serialize)]
pub struct WakeRespondResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bloom: Option<BloomFull>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attempt: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<BloomPrompt>,
pub session: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<BloomPrompt>,
#[serde(skip_serializing_if = "Option::is_none")]
pub progress: Option<Progress>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<Summary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub derived_phrase_mismatch: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct WakeSkipResponse {
pub status: String,
pub bloom: BloomFull,
pub session: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<BloomPrompt>,
#[serde(skip_serializing_if = "Option::is_none")]
pub progress: Option<Progress>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<Summary>,
}
#[derive(Debug, Serialize)]
pub struct WakeErrorResponse {
pub status: String,
pub error: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct BloomPrompt {
pub id: String,
pub title: String,
pub resonance: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub resonance_type: Option<String>,
pub wake_phrase_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub chunk: Option<ChunkRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phrase_source: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
pub struct ChunkRef {
pub index: u16,
pub total: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub oversized: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct BloomFull {
pub title: String,
pub content: String,
pub resonance: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub resonance_type: Option<String>,
pub all_phrases: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_phrase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chunk: Option<ChunkRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phrase_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chunk_truncated: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct Progress {
pub current: usize,
pub total: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub remembered: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub needed_help: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skipped: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bloom_current: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bloom_total: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct Summary {
pub total: usize,
pub remembered: u32,
pub needed_help: u32,
pub skipped: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub blooms_complete: Option<Vec<BloomRollup>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chunks_remembered: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chunks_skipped: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chunks_needed_help: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct BloomRollup {
pub id: String,
pub title: String,
pub chunks: String,
pub remembered: u32,
pub needed_help: u32,
pub skipped: u32,
pub total: u32,
#[serde(skip_serializing_if = "is_zero")]
pub authored_chunks: u32,
#[serde(skip_serializing_if = "is_zero")]
pub derived_chunks: u32,
#[serde(skip_serializing_if = "is_zero")]
pub auto_chunks: u32,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
impl From<&KnowledgeEntry> for BloomPrompt {
fn from(entry: &KnowledgeEntry) -> Self {
let phrase_count = authored_phrase_count(entry) as usize;
Self {
id: entry.id.clone(),
title: entry.title.clone(),
resonance: entry.resonance,
resonance_type: entry.resonance_type.clone(),
wake_phrase_count: phrase_count,
chunk: None,
phrase_source: None,
}
}
}
impl From<&KnowledgeEntry> for BloomFull {
fn from(entry: &KnowledgeEntry) -> Self {
let content = entry
.body
.clone()
.or_else(|| entry.summary.clone())
.unwrap_or_else(|| "(no content)".to_string());
let all_phrases = if !entry.wake_phrases.is_empty() {
entry.wake_phrases.clone()
} else if let Some(ref phrase) = entry.wake_phrase {
vec![phrase.clone()]
} else {
vec![]
};
Self {
title: entry.title.clone(),
content,
resonance: entry.resonance,
resonance_type: entry.resonance_type.clone(),
all_phrases,
matched_phrase: None,
chunk: None,
phrase_source: None,
chunk_truncated: None,
}
}
}