Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .beads/interactions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -529,3 +529,12 @@
{"id":"int-1a604fb1bb652a247f93660f85c3105b","kind":"field_change","created_at":"2026-08-08T18:11:15.685177178Z","actor":"Dhar Rawal","issue_id":"fix-1in.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
{"id":"int-b3c36247b3e8862b12e87c057c19ad29","kind":"field_change","created_at":"2026-08-08T18:11:16.39822319Z","actor":"Dhar Rawal","issue_id":"fix-1in","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
{"id":"int-b53eb85f236734101eaa670d8de6c6ad","kind":"field_change","created_at":"2026-08-08T19:44:59.853945913Z","actor":"Dhar Rawal","issue_id":"fix-ry7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"PR #56 applied with Sourcery review fixes; targeted + full pytest green"}}
{"id":"int-6ff3fca77a76d8abddb06a72a48eab60","kind":"field_change","created_at":"2026-08-08T21:58:50.658033307Z","actor":"Dhar Rawal","issue_id":"fix-lvl.8","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
{"id":"int-f83ec8304c5f573d83444cb0d6cfc9a2","kind":"field_change","created_at":"2026-08-08T21:58:54.909404133Z","actor":"Dhar Rawal","issue_id":"fix-lvl.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
{"id":"int-b75136c7fe586dd723ef9d79debdb3a9","kind":"field_change","created_at":"2026-08-08T21:58:55.654856178Z","actor":"Dhar Rawal","issue_id":"fix-lvl.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
{"id":"int-51badfb2ebc8019682b80ff8c7f1e57d","kind":"field_change","created_at":"2026-08-08T21:58:56.596857655Z","actor":"Dhar Rawal","issue_id":"fix-lvl.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
{"id":"int-a2b8c5f36f4de5d3f68dfc01830f9e5a","kind":"field_change","created_at":"2026-08-08T21:58:57.291280264Z","actor":"Dhar Rawal","issue_id":"fix-lvl.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
{"id":"int-b089cab161efe38dd7e4d018d564ea0d","kind":"field_change","created_at":"2026-08-08T21:58:58.09581554Z","actor":"Dhar Rawal","issue_id":"fix-lvl.5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
{"id":"int-bb14552030f257d7ff413535c2394f05","kind":"field_change","created_at":"2026-08-08T21:58:58.842982682Z","actor":"Dhar Rawal","issue_id":"fix-lvl.6","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
{"id":"int-cf0e8785fef0883771ec8057f02a4f79","kind":"field_change","created_at":"2026-08-08T21:58:59.663124247Z","actor":"Dhar Rawal","issue_id":"fix-lvl.7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
{"id":"int-4f8cc573e4d1dcde4033d4da6211a960","kind":"field_change","created_at":"2026-08-08T21:59:00.341228885Z","actor":"Dhar Rawal","issue_id":"fix-lvl","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
16 changes: 13 additions & 3 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ This is the fastest way to see fastWorkflow in action.
</p>

```sh
# 1. Install (Linux/macOS; on Windows use WSL. Python 3.11+)
# 1. Install (Linux/macOS; on Windows use WSL. Python 3.13+)
pip install fastworkflow

# 2. Fetch the hello_world example + env file templates
Expand Down Expand Up @@ -494,9 +494,10 @@ pip install "fastworkflow[training]" # adds HuggingFace datasets for the train
```

**Notes**
- Linux/macOS only — on Windows use WSL. Python 3.11+.
- Linux/macOS only — on Windows use WSL. Python 3.13–3.14 (stdlib `sqlite3` replaced the abandoned `speedict`/RocksDB dependency that blocked 3.13 installs).
- Installs PyTorch; the first install may take a few minutes.
- `fastworkflow train` needs the optional HuggingFace `datasets` package (`pip install datasets`, or `poetry install --with dev` from this repo).
- On-disk conversation stores are now `{channel_id}.sqlite3` under `SPEEDDICT_FOLDERNAME/channel_conversations`. NLU caches use `*.sqlite3` under `___convo_info/`. Pre-existing RocksDB `.rdb` / `cache.db` directories are unused and may be deleted. Downstream shims that aliased `speedict.Rdict` to `rocksdict.Rdict` can be removed.

The core depends on **plain** `litellm` (client only — no proxy server stack), so it co-installs cleanly with downstream apps that pin a plain `litellm`. Server-only deps live behind the `server` extra.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
from concurrent.futures import ThreadPoolExecutor, as_completed

from pydantic import BaseModel
from speedict import Rdict

import fastworkflow
from fastworkflow.utils.logging import logger
from fastworkflow import NLUPipelineStage
from fastworkflow.cache_matching import cache_match, store_utterance_cache
from fastworkflow.kvstore import KVStore
from fastworkflow.model_pipeline_training import (
CommandRouter
)
Expand Down Expand Up @@ -194,7 +194,7 @@ def _get_cache_path(workflow_id, convo_path):
base_dir = convo_path
# Create directory if it doesn't exist
os.makedirs(base_dir, exist_ok=True)
return os.path.join(base_dir, f"{workflow_id}.db")
return os.path.join(base_dir, f"{workflow_id}.sqlite3")

@staticmethod
def _get_cache_path_cache(convo_path):
Expand All @@ -204,7 +204,7 @@ def _get_cache_path_cache(convo_path):
base_dir = convo_path
# Create directory if it doesn't exist
os.makedirs(base_dir, exist_ok=True)
return os.path.join(base_dir, "cache.db")
return os.path.join(base_dir, "cache.sqlite3")

# Store the suggested commands with the flag type
@staticmethod
Expand All @@ -217,53 +217,39 @@ def _store_suggested_commands(cache_path, command_list, flag_type):
command_list: List of suggested commands
flag_type: Type of constraint (1=ambiguous, 2=misclassified)
"""
db = Rdict(cache_path)
try:
db["suggested_commands"] = command_list
db["flag_type"] = flag_type
finally:
db.close()
with KVStore(cache_path) as db:
# predict() returns a numpy ndarray of labels; JSON needs plain strs.
db["suggested_commands"] = [str(c) for c in list(command_list)]
db["flag_type"] = int(flag_type)

# Get the suggested commands
@staticmethod
def _get_suggested_commands(cache_path):
"""
Get the list of suggested commands for the constrained selection
"""
db = Rdict(cache_path)
try:
with KVStore(cache_path) as db:
return db.get("suggested_commands", [])
finally:
db.close()

@staticmethod
def _get_count(cache_path):
db = Rdict(cache_path)
try:
with KVStore(cache_path) as db:
return db.get("utterance_count", 0) # Default to 0 if key doesn't exist
finally:
db.close()

@staticmethod
def _print_db_contents(cache_path):
db = Rdict(cache_path)
try:
with KVStore(cache_path) as db:
print("All keys in database:", list(db.keys()))
for key in db.keys():
print(f"Key: {key}, Value: {db[key]}")
finally:
db.close()

@staticmethod
def _store_utterance(cache_path, utterance, label):
"""
Store utterance in existing or new database
Returns: The utterance count used
"""
# Open the database (creates if doesn't exist)
db = Rdict(cache_path)

try:
with KVStore(cache_path) as db:
# Get existing counter or initialize to 0
utterance_count = db.get("utterance_count", 0)

Expand All @@ -281,22 +267,14 @@ def _store_utterance(cache_path, utterance, label):

return utterance_count - 1 # Return the count used for this utterance

finally:
# Always close the database
db.close()

# Function to read from database
@staticmethod
def _read_utterance(cache_path, utterance_id):
"""
Read a specific utterance from the database
"""
db = Rdict(cache_path)
try:
with KVStore(cache_path) as db:
return db.get(utterance_id)['utterance']
finally:
db.close()

@staticmethod
def resolve_fully_qualified_command_name(
command_name: Optional[str], command_name_dict: dict[str, str]) -> Optional[str]:
Expand Down
110 changes: 50 additions & 60 deletions fastworkflow/cache_matching.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
import fastworkflow
import torch
from speedict import Rdict
import mmh3 # mmh33 implementation
from datetime import datetime
from functools import lru_cache
import weakref

from fastworkflow.kvstore import UtteranceCacheStore

# ---------------------------------------------------------------------
# In-process memoisation for expensive DistilBERT embeddings.
Expand All @@ -26,7 +27,6 @@ def _cached_embedding(model_id: int, text: str):
raise RuntimeError("ModelPipeline instance no longer alive; cache invalid.")
return _compute_embedding(text, pipeline)

import weakref
_MODEL_ID_2_REF: dict[int, weakref.ReferenceType] = {}


Expand Down Expand Up @@ -63,59 +63,56 @@ def store_utterance_cache(cache_path, utterance, label, model_pipeline=None):
Returns:
The hash key of the stored utterance
"""
# Open the database
db = Rdict(cache_path)
try:
with UtteranceCacheStore(cache_path) as db:
# Generate hash for utterance using mmh3
utterance_hash = str(mmh3.hash(utterance))

# Get the cache or initialize
cache = db.get("cache", {})

# Get current timestamp for feedback date
current_time = datetime.now().isoformat()

# Compute embedding if model_pipeline provided
embedding = None
if model_pipeline is not None:
embedding = get_embedding(utterance, model_pipeline)[0].tolist()

if utterance_hash in cache:
# Update existing entry
embedding = get_embedding(utterance, model_pipeline)[0]

existing = db.get(utterance_hash)
if existing is not None:
command_mapping = existing["command_mapping"]
stored_embedding = existing["embedding"]
stored_utterance = existing["utterance"]

if embedding is not None:
cache[utterance_hash]["embedding"] = embedding

if label in cache[utterance_hash]["command_mapping"]:
# Increment frequency for this label
cache[utterance_hash]["command_mapping"][label]["frequency"] += 1
cache[utterance_hash]["command_mapping"][label]["feedback_date"] = current_time
stored_embedding = embedding

if label in command_mapping:
command_mapping[label]["frequency"] += 1
command_mapping[label]["feedback_date"] = current_time
else:
# Add new label mapping
cache[utterance_hash]["command_mapping"][label] = {
command_mapping[label] = {
"frequency": 1,
"feedback_date": current_time
}

db.upsert(
utterance_hash,
utterance=stored_utterance or utterance,
command_mapping=command_mapping,
embedding=stored_embedding,
)
else:
# Create new entry
cache[utterance_hash] = {
"embedding": embedding if embedding is not None else [],
"utterance": utterance, # Store original utterance for reference
"command_mapping": {
db.upsert(
utterance_hash,
utterance=utterance,
command_mapping={
label: {
"frequency": 1,
"feedback_date": current_time
}
}
}

# Save updated cache to database
db["cache"] = cache
},
embedding=embedding if embedding is not None else None,
)

return utterance_hash

finally:
# Always close the database
db.close()

def get_embedding(text: str, model_pipeline):
"""Return (possibly cached) embedding for *text* using *model_pipeline*."""
Expand Down Expand Up @@ -144,43 +141,39 @@ def cache_match(cache_path, utterance, model_pipeline, threshold=0.90, return_de
If match found: true_label or (true_label, similarity) if return_details=True
If no match: None
"""
# Open the database
db = Rdict(cache_path)
try:
# Get the cache dictionary
cache = db.get("cache", {})

# If no entries, return None
if not cache:
return None

with UtteranceCacheStore(cache_path) as db:
# Get embedding for the query utterance
query_embedding = get_embedding(utterance, model_pipeline)

# Reshape query embedding for cosine_similarity
query_embedding = query_embedding.reshape(1, -1)

# Check cache for similar utterances
# Check cache for similar utterances (stream; do not materialise all rows)
best_similarity = 0
cache_match = None

# Find the best matching cached utterance
for hash_key, entry in cache.items():
# Skip entries without embeddings
if not entry.get("embedding"):
best_key = None
best_mapping = None
saw_any = False

for hash_key, entry in db.iter_entries():
saw_any = True
cached_embedding = entry.get("embedding")
if cached_embedding is None or cached_embedding.size == 0:
continue

# Reshape cached embedding for cosine_similarity
cached_embedding = np.array(entry["embedding"]).reshape(1, -1)
cached_embedding = np.asarray(cached_embedding, dtype=np.float32).reshape(1, -1)
similarity = cosine_similarity(query_embedding, cached_embedding)[0][0]

if similarity > best_similarity:
best_similarity = similarity
cache_match = hash_key
best_key = hash_key
best_mapping = entry["command_mapping"]

if not saw_any:
return None

# If good cache match found, determine the best label
if best_similarity >= threshold and cache_match is not None:
command_mapping = cache[cache_match]["command_mapping"]
if best_similarity >= threshold and best_key is not None and best_mapping is not None:
command_mapping = best_mapping

# If only one label, return it directly
if len(command_mapping) == 1:
Expand Down Expand Up @@ -210,6 +203,3 @@ def cache_match(cache_path, utterance, model_pipeline, threshold=0.90, return_de
return (true_label, best_similarity) if return_details else true_label
# No good match found
return None
finally:
# Always close the database
db.close()
Loading
Loading