mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
Feature/twitter bot (#259)
This commit is contained in:
+3
-11
@@ -45,11 +45,14 @@ Thumbs.db
|
||||
*.db
|
||||
*.jsonl
|
||||
*.npz
|
||||
*.log
|
||||
results/
|
||||
logs/
|
||||
traces/
|
||||
coding_task_*
|
||||
get-pip.py
|
||||
# Junk from mocked-path tests that write to their mock's __repr__ as a path
|
||||
MagicMock/
|
||||
|
||||
# MkDocs build output
|
||||
site/
|
||||
@@ -91,18 +94,7 @@ src/openjarvis/channels/whatsapp_baileys_bridge/node_modules/
|
||||
|
||||
# SQLite in-memory artifacts
|
||||
:memory:
|
||||
MagicMock/
|
||||
|
||||
# Second Repos
|
||||
Inline/
|
||||
scratch/
|
||||
|
||||
# SQLite in-memory artifacts
|
||||
:memory:
|
||||
MagicMock/
|
||||
|
||||
# Second Repos
|
||||
Inline/
|
||||
|
||||
# Local git worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Overnight Slack preview — generates proactive tweets on a loop.
|
||||
|
||||
Posts to a single Slack thread so it doesn't spam the channel.
|
||||
Run in tmux and check the thread in the morning.
|
||||
|
||||
Usage:
|
||||
python examples/twitter_bot/slack_preview.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
SLACK_TOKEN = os.environ.get("SLACK_BOT_TOKEN", "")
|
||||
SLACK_CHANNEL = os.environ.get("SLACK_PREVIEW_CHANNEL", "")
|
||||
INTERVAL_MINUTES = 30
|
||||
|
||||
TOPICS = [
|
||||
# Narrative — opinionated takes that happen to be about OpenJarvis
|
||||
"mainframe personal computer computing shift efficiency",
|
||||
"Intelligence Per Watt study local models queries latency",
|
||||
"personal data cloud APIs privacy terms of service",
|
||||
"energy consumption NVIDIA Apple Silicon dollar cost constraints",
|
||||
"learning loop local traces model weights prompts optimization",
|
||||
"Stanford open source Apache research Hazy Scaling Intelligence",
|
||||
"cloud dependency personal AI thin orchestration layer brain",
|
||||
"local inference consumer hardware battery laptop efficiency",
|
||||
"open source tools local AI available everyone research",
|
||||
# Technical — but framed as narrative search queries, not product lookups
|
||||
"channel integrations messaging platforms connect send disconnect",
|
||||
"composable primitives intelligence engine agents tools learning",
|
||||
"inference engines hardware detection auto configure local",
|
||||
"memory retrieval backends keyword search vector similarity",
|
||||
]
|
||||
|
||||
FACTS = [
|
||||
"In the 70s and 80s computing moved from mainframes to personal computers. Not because PCs were more powerful, but because they became efficient enough for what people actually needed. AI is reaching a similar moment.",
|
||||
"In our Intelligence Per Watt study, we found that local language models can accurately service 88.7 percent of single-turn chat and reasoning queries at interactive latencies, with intelligence efficiency improving 5.3 times from 2023 to 2025.",
|
||||
"In nearly all personal AI projects today, the local component is a thin orchestration layer, while the brain lives in someone else data center. Your most personal data routes through cloud APIs, with their latency, their cost, and their terms of service. We built OpenJarvis to fix this.",
|
||||
"OpenJarvis is structured around five composable primitives: Intelligence, Engine, Agents, Tools and Memory, and Learning. Each primitive can be benchmarked, substituted, and optimized independently.",
|
||||
"OpenJarvis includes hardware-agnostic telemetry that profiles energy consumption across NVIDIA GPUs, AMD GPUs, and Apple Silicon. Energy and dollar cost are first-class design constraints alongside accuracy.",
|
||||
"The learning loop uses personal traces to synthesize training data, refine agent behavior, and improve model selection over time. Four optimization layers: model weights, LM prompts, agentic logic, inference engine.",
|
||||
"OpenJarvis is open source under Apache 2.0, built at Stanford at Hazy Research and the Scaling Intelligence Lab at SAIL. Because the tools for studying and building local-first AI should be available to everyone.",
|
||||
"OpenJarvis supports 27 channel integrations including Slack, Discord, Telegram, WhatsApp. Adding a new channel is one file implementing BaseChannel with connect, send, and disconnect.",
|
||||
"OpenJarvis supports multiple inference engines: Ollama, vLLM, SGLang, llama.cpp. jarvis init picks the right one for your hardware.",
|
||||
"Install OpenJarvis by running git clone https://github.com/open-jarvis/OpenJarvis.git then cd OpenJarvis then uv sync. Use jarvis init to auto-detect hardware and configure the engine.",
|
||||
"OpenJarvis memory and RAG supports four backends: SQLite FTS5 for keyword search, FAISS for vector similarity, ColBERT for token-level matching, and BM25 for probabilistic retrieval.",
|
||||
"OpenJarvis ships with nine example projects: deep_research, code_companion, messaging_hub, scheduled_ops, browser_assistant, security_scanner, daily_digest, doc_qa, and multi_model_router.",
|
||||
]
|
||||
|
||||
|
||||
def slack_send(text: str, thread_ts: str = "") -> str:
|
||||
payload: dict = {"channel": SLACK_CHANNEL, "text": text}
|
||||
if thread_ts:
|
||||
payload["thread_ts"] = thread_ts
|
||||
resp = httpx.post(
|
||||
"https://slack.com/api/chat.postMessage",
|
||||
headers={
|
||||
"Authorization": f"Bearer {SLACK_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=10.0,
|
||||
)
|
||||
return resp.json().get("ts", "")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from openjarvis import Jarvis
|
||||
from openjarvis.core.events import EventType
|
||||
|
||||
print("Starting overnight Slack preview...")
|
||||
print(f"Posting to #{SLACK_CHANNEL} every ~{INTERVAL_MINUTES} min")
|
||||
print()
|
||||
|
||||
j = Jarvis(model="qwen3:32b", engine_key="ollama")
|
||||
j._config.agent.max_turns = 5
|
||||
|
||||
# Clean and index
|
||||
db_path = os.path.expanduser("~/.openjarvis/memory.db")
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
|
||||
print("Indexing facts...")
|
||||
for fact in FACTS:
|
||||
j.ask_full(
|
||||
"Store:\n\n" + fact,
|
||||
agent="orchestrator",
|
||||
tools=["memory_store"],
|
||||
temperature=0.1,
|
||||
)
|
||||
print(f"Indexed {len(FACTS)} facts.\n")
|
||||
|
||||
# Track tool calls
|
||||
tool_log: list[dict] = []
|
||||
|
||||
def on_tool(event):
|
||||
tool_log.append({
|
||||
"tool": event.data.get("tool", ""),
|
||||
"args": event.data.get("arguments", ""),
|
||||
})
|
||||
|
||||
j._bus.subscribe(EventType.TOOL_CALL_START, on_tool)
|
||||
|
||||
# Post parent message
|
||||
parent_ts = slack_send(
|
||||
"*OpenJarvis Twitter Bot — Overnight Preview*\n"
|
||||
f"Generating a tweet every ~{INTERVAL_MINUTES} min. "
|
||||
"Check this thread in the morning.",
|
||||
)
|
||||
print(f"Parent message posted (ts={parent_ts})")
|
||||
|
||||
recent: list[str] = []
|
||||
tweet_count = 0
|
||||
cycle = 0
|
||||
|
||||
while True:
|
||||
cycle += 1
|
||||
topic = random.choice(TOPICS)
|
||||
|
||||
recent_section = ""
|
||||
if recent:
|
||||
recent_list = "\n".join(' - "' + t + '"' for t in recent[-8:])
|
||||
recent_section = (
|
||||
"Your recent tweets (DO NOT repeat any of these ideas "
|
||||
"— write something completely different):\n"
|
||||
+ recent_list
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
tool_log.clear()
|
||||
r = j.ask_full(
|
||||
"You are @OpenJarvisAI — a researcher/dev who believes local-first "
|
||||
"AI is the future. Write one tweet. all lowercase, <=280 chars.\n\n"
|
||||
f'1. memory_search: "{topic}"\n'
|
||||
"2. Write a tweet that makes someone stop scrolling. Lead with "
|
||||
"an insight, a question, or a strong take. Use facts from "
|
||||
"the search results.\n"
|
||||
+ recent_section
|
||||
+ '3. channel_send channel="twitter", content=<tweet>. '
|
||||
"No conversation_id.\n\n"
|
||||
"Tweets we love:\n"
|
||||
'- "88.7% of queries run fine on local hardware. why is '
|
||||
'everyone still paying per API call?"\n'
|
||||
'- "your most personal data routes through someone else\'s '
|
||||
'server. we built openjarvis to fix that"\n'
|
||||
'- "in the 70s computing moved from mainframes to pcs. not '
|
||||
'because pcs were more powerful — because they got efficient '
|
||||
'enough. ai is at that moment right now"\n'
|
||||
'- "we measure energy per query the way most people measure '
|
||||
'accuracy. if your ai runs on battery, efficiency is the whole game"\n'
|
||||
'- "four rag backends, swap with one config change. been '
|
||||
'testing colbert on our docs and the retrieval quality jump is real"\n\n'
|
||||
"Only real facts. No invented stats. "
|
||||
"Link: https://github.com/open-jarvis/OpenJarvis",
|
||||
agent="orchestrator",
|
||||
tools=["think", "memory_search", "channel_send"],
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
# Extract tweet from channel_send call, or fall back to response text
|
||||
# (the model sometimes writes the tweet in its response instead of
|
||||
# calling channel_send)
|
||||
tweet = ""
|
||||
for tc in tool_log:
|
||||
if tc["tool"] == "channel_send":
|
||||
a = (
|
||||
tc["args"]
|
||||
if isinstance(tc["args"], dict)
|
||||
else (json.loads(tc["args"]) if tc["args"] else {})
|
||||
)
|
||||
tweet = a.get("content", "")
|
||||
break
|
||||
|
||||
if not tweet:
|
||||
# Fallback: extract from response text
|
||||
raw = r.get("content", "").strip()
|
||||
# Strip quotes if the model wrapped it
|
||||
if raw.startswith('"') and raw.endswith('"'):
|
||||
raw = raw[1:-1]
|
||||
# Only use it if it looks like a tweet (short, not an explanation)
|
||||
if 0 < len(raw) <= 300 and "here's" not in raw.lower():
|
||||
tweet = raw[:280]
|
||||
|
||||
ts = time.strftime("%H:%M")
|
||||
if tweet:
|
||||
tweet_count += 1
|
||||
slack_send(f"{tweet_count}. {tweet}", thread_ts=parent_ts)
|
||||
recent.append(tweet)
|
||||
# Keep last 10 for context
|
||||
if len(recent) > 10:
|
||||
recent.pop(0)
|
||||
print(f"[{ts}] #{tweet_count}: {tweet[:80]}...")
|
||||
else:
|
||||
print(f"[{ts}] cycle {cycle}: empty, skipped")
|
||||
|
||||
# Wait with jitter
|
||||
jitter = random.uniform(0.8, 1.2)
|
||||
wait = INTERVAL_MINUTES * 60 * jitter
|
||||
print(f" next tweet in {wait/60:.0f} min")
|
||||
time.sleep(wait)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify that dense retrieval grounds the bot's reply in real doc facts.
|
||||
|
||||
Question: "can I run the orchestrator agent on a laptop without a gpu?"
|
||||
Ground truth is scattered across docs/ — mentions of ``llama.cpp`` (pure
|
||||
C++ inference, "ideal for laptops without a discrete GPU"), CPU-only
|
||||
mode, 4B-model recommendation on CPU, Metal on Apple Silicon.
|
||||
|
||||
Stages:
|
||||
1. **Unground**: backend empty → bot hits the deferral prompt path.
|
||||
2. **Grounded**: DenseMemory indexed over README + docs/ → bot hits
|
||||
the grounded prompt with retrieved context embedded.
|
||||
|
||||
Success: stage 2's reply cites one of the concrete doc facts
|
||||
(``llama.cpp``, ``4B``, ``metal``, ``cpu-only``, etc.) that are not in
|
||||
the ungrounded baseline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_THIS = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_THIS))
|
||||
twitter_bot = importlib.import_module("twitter_bot")
|
||||
sys.path.pop(0)
|
||||
|
||||
_resolve_question_prompt = twitter_bot._resolve_question_prompt
|
||||
_DemoChannel = twitter_bot._DemoChannel
|
||||
SCORE_THRESHOLD = twitter_bot.SCORE_THRESHOLD
|
||||
|
||||
|
||||
MENTION = {
|
||||
"id": "3000000000000000001",
|
||||
"author": "indie_hacker",
|
||||
"text": "@OpenJarvisAI can I run the orchestrator agent on a laptop without a gpu?",
|
||||
}
|
||||
|
||||
|
||||
def _ask_once(j, demo_channel, backend):
|
||||
prompt, top_score = _resolve_question_prompt(
|
||||
backend, MENTION["author"], MENTION["id"], MENTION["text"],
|
||||
)
|
||||
demo_channel.last_sent = None
|
||||
response = j.ask(
|
||||
prompt,
|
||||
agent="orchestrator",
|
||||
tools=["channel_send"],
|
||||
temperature=0.4,
|
||||
channel=demo_channel,
|
||||
)
|
||||
return demo_channel.last_sent or response, top_score
|
||||
|
||||
|
||||
def main():
|
||||
from openjarvis import Jarvis
|
||||
from openjarvis.tools.storage.dense import DenseMemory
|
||||
|
||||
sys.path.insert(0, str(_THIS.parents[1] / "scripts"))
|
||||
from index_docs import build_index # type: ignore
|
||||
sys.path.pop(0)
|
||||
|
||||
model = "gemma4:31b"
|
||||
j = Jarvis(model=model, engine_key="ollama")
|
||||
demo_channel = _DemoChannel()
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
|
||||
print(f"Test question: {MENTION['text']}")
|
||||
print(f"Score threshold: {SCORE_THRESHOLD}\n")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
print("=" * 80)
|
||||
print("STAGE 1: No retrieval — forced deferral path")
|
||||
print("=" * 80)
|
||||
empty_backend = DenseMemory() # empty index
|
||||
reply_1, score_1 = _ask_once(j, demo_channel, empty_backend)
|
||||
print(f"top_score={score_1:.3f} (threshold={SCORE_THRESHOLD})")
|
||||
print(f"Reply: {reply_1}\n")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
print("=" * 80)
|
||||
print("STAGE 2: Full docs indexed — grounded path if score >= threshold")
|
||||
print("=" * 80)
|
||||
backend = build_index(repo_root)
|
||||
print(f"Indexed {backend.count()} chunks from README + docs/")
|
||||
|
||||
# Show what the retriever actually surfaces for the mention text
|
||||
print("\nTop 3 hits for the mention text:")
|
||||
hits = backend.retrieve(MENTION["text"], top_k=3)
|
||||
for i, h in enumerate(hits, 1):
|
||||
preview = h.content.replace("\n", " ")[:180]
|
||||
print(
|
||||
f" [{i}] score={h.score:.3f} src={h.source}"
|
||||
f" bc={h.metadata.get('breadcrumb', '')}"
|
||||
)
|
||||
print(f" {preview}{'...' if len(h.content) > 180 else ''}")
|
||||
print()
|
||||
|
||||
reply_2, score_2 = _ask_once(j, demo_channel, backend)
|
||||
print(f"top_score={score_2:.3f} (threshold={SCORE_THRESHOLD})")
|
||||
print(f"Reply: {reply_2}\n")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
print("=" * 80)
|
||||
print("COMPARISON")
|
||||
print("=" * 80)
|
||||
print(f"Without docs (score {score_1:.2f}): {reply_1}")
|
||||
print(f"With docs (score {score_2:.2f}): {reply_2}")
|
||||
print()
|
||||
|
||||
grounding_terms = [
|
||||
"llama.cpp", "llama cpp", "4b", "metal", "apple silicon",
|
||||
"cpu-only", "cpu only", "rocm", "quantization", "ollama",
|
||||
"vllm", "sglang", "mlx",
|
||||
]
|
||||
r2_lower = reply_2.lower()
|
||||
mentioned = [t for t in grounding_terms if t in r2_lower]
|
||||
print(f"Grounding signals in stage 2 reply: {mentioned or 'none'}")
|
||||
|
||||
j.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stress-test the twitter bot on diverse mentions with the dense-retrieval pipeline.
|
||||
|
||||
For each mention:
|
||||
* captures the actual ``channel_send`` payload (the tweet that would be sent)
|
||||
* for QUESTIONs, records whether retrieval scored above threshold
|
||||
(grounded path) or below (deferral path)
|
||||
* scores the reply against voice rules
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_THIS = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_THIS))
|
||||
twitter_bot = importlib.import_module("twitter_bot")
|
||||
sys.path.pop(0)
|
||||
|
||||
_classify_mention = twitter_bot._classify_mention
|
||||
_build_bug_prompt = twitter_bot._build_bug_prompt
|
||||
_build_feature_prompt = twitter_bot._build_feature_prompt
|
||||
_build_praise_prompt = twitter_bot._build_praise_prompt
|
||||
_resolve_question_prompt = twitter_bot._resolve_question_prompt
|
||||
_DemoChannel = twitter_bot._DemoChannel
|
||||
SCORE_THRESHOLD = twitter_bot.SCORE_THRESHOLD
|
||||
|
||||
|
||||
REAL_WORLD_MENTIONS = [
|
||||
# === QUESTIONs that should ground (docs cover them) ===
|
||||
{
|
||||
"id": "3000000000000000001",
|
||||
"author": "ml_researcher",
|
||||
"text": (
|
||||
"@OpenJarvisAI does this work with vllm or "
|
||||
"do I need ollama specifically?"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "3000000000000000002",
|
||||
"author": "indie_hacker",
|
||||
"text": (
|
||||
"@OpenJarvisAI can I run the orchestrator agent "
|
||||
"on a laptop without a gpu?"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "3000000000000000003",
|
||||
"author": "macuser",
|
||||
"text": "@OpenJarvisAI how do I install on macos with apple silicon?",
|
||||
},
|
||||
{
|
||||
"id": "3000000000000000004",
|
||||
"author": "longwinded_lou",
|
||||
"text": (
|
||||
"@OpenJarvisAI how does the memory system handle "
|
||||
"conflicting facts? overwrite or keep both?"
|
||||
),
|
||||
},
|
||||
|
||||
# === QUESTIONs that should defer (off-topic / unknowable) ===
|
||||
{
|
||||
"id": "3000000000000000005",
|
||||
"author": "off_topic_olive",
|
||||
"text": "@OpenJarvisAI what's the weather in tokyo today?",
|
||||
},
|
||||
{
|
||||
"id": "3000000000000000006",
|
||||
"author": "specific_specs",
|
||||
"text": (
|
||||
"@OpenJarvisAI what's the exact tokens-per-second "
|
||||
"on an M3 Pro with the 70B model?"
|
||||
),
|
||||
},
|
||||
|
||||
# === BUG / FEATURE / PRAISE / SPAM ===
|
||||
{
|
||||
"id": "3000000000000000007",
|
||||
"author": "devops_dan",
|
||||
"text": (
|
||||
"@OpenJarvisAI getting a segfault on startup "
|
||||
"with the lemonade backend, 0.18.2"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "3000000000000000008",
|
||||
"author": "enterprise_eng",
|
||||
"text": (
|
||||
"@OpenJarvisAI any plans for SSO support? "
|
||||
"would love to deploy this internally"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "3000000000000000009",
|
||||
"author": "convert_carl",
|
||||
"text": (
|
||||
"@OpenJarvisAI switched from langchain last week, this is incredible"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "3000000000000000010",
|
||||
"author": "crypto_bro",
|
||||
"text": "@OpenJarvisAI BUY $JARVIS COIN guaranteed 10x gains link in bio",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
_EMOJI_RE = re.compile(
|
||||
r"[\U0001F300-\U0001FAFF"
|
||||
r"\U00002600-\U000027BF"
|
||||
r"\U0001F900-\U0001F9FF]",
|
||||
)
|
||||
|
||||
|
||||
def _check_voice(reply: str) -> dict:
|
||||
has_upper = bool(re.search(r"[A-Z]", reply))
|
||||
has_emoji = bool(_EMOJI_RE.search(reply))
|
||||
has_hashtag = "#" in reply
|
||||
return {
|
||||
"len": len(reply),
|
||||
"<=280": len(reply) <= 280,
|
||||
"lowercase": not has_upper,
|
||||
"no_emoji": not has_emoji,
|
||||
"no_hashtag": not has_hashtag,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
from openjarvis import Jarvis
|
||||
|
||||
sys.path.insert(0, str(_THIS.parents[1] / "scripts"))
|
||||
from index_docs import build_index # type: ignore
|
||||
sys.path.pop(0)
|
||||
|
||||
model = "gemma4:31b"
|
||||
print("Building dense index from README + docs/...", flush=True)
|
||||
backend = build_index(Path(__file__).resolve().parents[2])
|
||||
print(f"Indexed {backend.count()} chunks.\n", flush=True)
|
||||
|
||||
j = Jarvis(model=model, engine_key="ollama")
|
||||
demo_channel = _DemoChannel()
|
||||
|
||||
results = []
|
||||
print(f"Testing {len(REAL_WORLD_MENTIONS)} mentions with {model}...\n", flush=True)
|
||||
|
||||
try:
|
||||
for idx, tweet in enumerate(REAL_WORLD_MENTIONS, 1):
|
||||
mention_type = _classify_mention(tweet["text"])
|
||||
print(
|
||||
f"[{idx}/{len(REAL_WORLD_MENTIONS)}] [{mention_type}] "
|
||||
f"@{tweet['author']}: {tweet['text'][:70]}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
entry = {
|
||||
"n": idx,
|
||||
"author": tweet["author"],
|
||||
"text": tweet["text"],
|
||||
"type": mention_type,
|
||||
"reply": "[ignored]",
|
||||
"voice": None,
|
||||
"ground_state": "",
|
||||
"score": 0.0,
|
||||
"secs": 0.0,
|
||||
}
|
||||
|
||||
if mention_type == "SPAM":
|
||||
results.append(entry)
|
||||
print(" -> [ignored]\n", flush=True)
|
||||
continue
|
||||
|
||||
if mention_type == "QUESTION":
|
||||
prompt, score = _resolve_question_prompt(
|
||||
backend, tweet["author"], tweet["id"], tweet["text"],
|
||||
)
|
||||
tools = ["channel_send"]
|
||||
entry["score"] = score
|
||||
entry["ground_state"] = (
|
||||
"grounded" if score >= SCORE_THRESHOLD else "deferred"
|
||||
)
|
||||
print(
|
||||
f" retrieval top-1: {score:.3f} -> {entry['ground_state']}",
|
||||
flush=True,
|
||||
)
|
||||
elif mention_type == "BUG_REPORT":
|
||||
prompt = _build_bug_prompt(
|
||||
tweet["author"], tweet["id"], tweet["text"],
|
||||
)
|
||||
tools = ["http_request", "channel_send"]
|
||||
elif mention_type == "FEATURE_REQUEST":
|
||||
prompt = _build_feature_prompt(
|
||||
tweet["author"], tweet["id"], tweet["text"],
|
||||
)
|
||||
tools = ["http_request", "channel_send"]
|
||||
else:
|
||||
prompt = _build_praise_prompt(
|
||||
tweet["author"], tweet["id"], tweet["text"],
|
||||
)
|
||||
tools = ["channel_send"]
|
||||
|
||||
demo_channel.last_sent = None
|
||||
t0 = time.time()
|
||||
try:
|
||||
response = j.ask(
|
||||
prompt,
|
||||
agent="orchestrator",
|
||||
tools=tools,
|
||||
temperature=0.4,
|
||||
channel=demo_channel,
|
||||
)
|
||||
except Exception as exc:
|
||||
response = f"<error: {exc}>"
|
||||
elapsed = time.time() - t0
|
||||
|
||||
reply = demo_channel.last_sent or response
|
||||
entry["reply"] = reply
|
||||
entry["voice"] = _check_voice(reply)
|
||||
entry["secs"] = elapsed
|
||||
results.append(entry)
|
||||
print(f" -> {reply[:140]}", flush=True)
|
||||
print(f" ({elapsed:.1f}s)\n", flush=True)
|
||||
finally:
|
||||
j.close()
|
||||
|
||||
# Print results table
|
||||
print("\n" + "=" * 110)
|
||||
print("RESULTS TABLE")
|
||||
print("=" * 110 + "\n")
|
||||
|
||||
print("| # | Type | State | Score | Mention | Reply | Len | voice OK |")
|
||||
print("|---|------|-------|-------|---------|-------|-----|----------|")
|
||||
for r in results:
|
||||
if r["voice"] is None:
|
||||
print(
|
||||
f"| {r['n']} | {r['type']} | - | - | "
|
||||
f"@{r['author']}: {r['text'][:50]}... "
|
||||
"| _[ignored]_ | - | - |",
|
||||
)
|
||||
else:
|
||||
v = r["voice"]
|
||||
ok = all([v["<=280"], v["lowercase"], v["no_emoji"], v["no_hashtag"]])
|
||||
score_str = f"{r['score']:.2f}" if r['type'] == 'QUESTION' else "-"
|
||||
state = r["ground_state"] or "-"
|
||||
short_reply = r["reply"][:80].replace("\n", " ").replace("|", "/")
|
||||
short_text = r["text"][:50].replace("|", "/")
|
||||
reply_suffix = "..." if len(r["reply"]) > 80 else ""
|
||||
print(
|
||||
f"| {r['n']} | {r['type'][:8]} | {state} | {score_str} | "
|
||||
f"@{r['author']}: {short_text}... "
|
||||
f"| {short_reply}{reply_suffix} "
|
||||
f"| {v['len']} | {'yes' if ok else 'NO'} |",
|
||||
)
|
||||
|
||||
# Voice rules summary
|
||||
scored = [r for r in results if r["voice"] is not None]
|
||||
if scored:
|
||||
n = len(scored)
|
||||
n280 = sum(1 for r in scored if r["voice"]["<=280"])
|
||||
nlow = sum(1 for r in scored if r["voice"]["lowercase"])
|
||||
nemo = sum(1 for r in scored if r["voice"]["no_emoji"])
|
||||
nhash = sum(1 for r in scored if r["voice"]["no_hashtag"])
|
||||
print(
|
||||
f"\nVoice compliance: <=280: {n280}/{n}, "
|
||||
f"lowercase: {nlow}/{n}, no emoji: {nemo}/{n}, "
|
||||
f"no hashtag: {nhash}/{n}",
|
||||
)
|
||||
avg_secs = sum(r["secs"] for r in scored) / n
|
||||
print(f"Avg latency: {avg_secs:.1f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,765 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenJarvis Twitter Bot — @OpenJarvisAI reactive mention handler.
|
||||
|
||||
Listens for @mentions and responds: answers questions, creates GitHub issues
|
||||
for bugs/feature requests, acknowledges praise, ignores spam. Like @grok.
|
||||
|
||||
Usage:
|
||||
python examples/twitter_bot/twitter_bot.py --demo
|
||||
python examples/twitter_bot/twitter_bot.py --live
|
||||
python examples/twitter_bot/twitter_bot.py --live --index-docs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
|
||||
class _DemoChannel:
|
||||
"""Stub channel for demo mode.
|
||||
|
||||
Accepts ``send()`` calls and records the content so the demo can
|
||||
display exactly what would be tweeted — instead of the agent's
|
||||
post-error fallback text (which bypasses the voice rules).
|
||||
"""
|
||||
|
||||
channel_id = "demo"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.last_sent: str | None = None
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: dict | None = None,
|
||||
) -> bool:
|
||||
self.last_sent = content
|
||||
return True
|
||||
|
||||
# Unused in demo but required by ChannelSendTool duck-typing
|
||||
def connect(self) -> None: ...
|
||||
def disconnect(self) -> None: ...
|
||||
|
||||
|
||||
DEMO_TWEETS = [
|
||||
{
|
||||
"id": "1000000000000000001",
|
||||
"author": "alice_dev",
|
||||
"text": "@OpenJarvisAI how do I add a new channel integration?",
|
||||
},
|
||||
{
|
||||
"id": "1000000000000000002",
|
||||
"author": "bob_user",
|
||||
"text": (
|
||||
"@OpenJarvisAI bug: the memory_search tool crashes "
|
||||
"when the index is empty"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "1000000000000000003",
|
||||
"author": "carol_eng",
|
||||
"text": "@OpenJarvisAI it would be great to have a built-in scheduler UI",
|
||||
},
|
||||
{
|
||||
"id": "1000000000000000004",
|
||||
"author": "dave_fan",
|
||||
"text": "@OpenJarvisAI just discovered this project, absolutely love it!",
|
||||
},
|
||||
{
|
||||
"id": "1000000000000000005",
|
||||
"author": "spambot99",
|
||||
"text": "@OpenJarvisAI BUY CRYPTO NOW 🚀🚀🚀 LINK IN BIO",
|
||||
},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retrieval-grounded question handling
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# For QUESTION mentions we do dense retrieval in Python before the agent
|
||||
# runs, then route to one of two prompts based on the top-1 cosine score:
|
||||
#
|
||||
# * ``_build_question_grounded_prompt`` — top-1 >= SCORE_THRESHOLD.
|
||||
# The retrieved context is embedded directly in the prompt. The model
|
||||
# only needs ``channel_send``.
|
||||
# * ``_build_question_deferral_prompt`` — top-1 < SCORE_THRESHOLD.
|
||||
# No context worth grounding on. The model is told to post a short
|
||||
# honest deferral.
|
||||
#
|
||||
# Threshold rationale: see tests/tools/storage/test_dense.py. With
|
||||
# nomic-embed-text on the OpenJarvis fixture corpus, relevant queries
|
||||
# top-1 scored 0.50-0.74 (median 0.68) and off-topic scored 0.40-0.51
|
||||
# (median 0.47). 0.55 biases toward deferral on borderline queries —
|
||||
# safer for public Twitter than grounding on a weak match.
|
||||
SCORE_THRESHOLD = 0.55
|
||||
|
||||
# Voice rules included in every per-call prompt so the model always sees them
|
||||
_VOICE = (
|
||||
"Rules for your reply:\n"
|
||||
"- lowercase prose; preserve URLs, code identifiers, and technical terms "
|
||||
"(model names, library names, file paths) as written.\n"
|
||||
"- <=280 characters.\n"
|
||||
"- no emojis. no hashtags.\n"
|
||||
"- casual and direct, like a dev helping another dev.\n"
|
||||
"- do not invent URLs, issue numbers, stats, commands, performance claims, "
|
||||
"or feature names. if you're not sure, don't guess.\n"
|
||||
)
|
||||
|
||||
|
||||
def _format_context(results) -> str:
|
||||
"""Render top retrieved chunks as a numbered list for the prompt."""
|
||||
out = []
|
||||
for i, r in enumerate(results, 1):
|
||||
src = r.source or "?"
|
||||
breadcrumb = r.metadata.get("breadcrumb", "") if r.metadata else ""
|
||||
header = f"[{i}] {src}"
|
||||
if breadcrumb and breadcrumb not in src:
|
||||
header += f" — {breadcrumb}"
|
||||
out.append(f"{header}\n{r.content}")
|
||||
return "\n\n---\n\n".join(out)
|
||||
|
||||
|
||||
def _build_question_grounded_prompt(
|
||||
author: str,
|
||||
tweet_id: str,
|
||||
text: str,
|
||||
context: str,
|
||||
top_score: float,
|
||||
) -> str:
|
||||
"""Prompt used when retrieval surfaces relevant content (top score >= threshold)."""
|
||||
return (
|
||||
"You are @OpenJarvisAI. Someone asked a question. We retrieved "
|
||||
f"context from the docs with top similarity {top_score:.2f}.\n\n"
|
||||
f"Tweet from @{author} (tweet ID: {tweet_id}):\n"
|
||||
f'"{text}"\n\n'
|
||||
"Retrieved context:\n"
|
||||
"=================\n"
|
||||
f"{context}\n"
|
||||
"=================\n\n"
|
||||
"Compose a reply ONLY from facts in the context above. Do not add "
|
||||
"details that are not in the context. If the context doesn't fully "
|
||||
"cover the question, answer the part that IS covered and defer on "
|
||||
"the rest (e.g. \"...not sure on the rest — will check\"). Then "
|
||||
f'call channel_send with conversation_id="{tweet_id}".\n\n'
|
||||
+ _VOICE
|
||||
)
|
||||
|
||||
|
||||
def _build_question_deferral_prompt(author: str, tweet_id: str, text: str) -> str:
|
||||
"""Prompt used when retrieval has nothing relevant (top score < threshold).
|
||||
|
||||
The model is told NOT to answer — because attempting to answer without
|
||||
grounding is the exact failure mode we're trying to avoid.
|
||||
"""
|
||||
return (
|
||||
"You are @OpenJarvisAI. Someone asked a question, but our docs "
|
||||
"search did not find relevant material — so we do NOT have a "
|
||||
"grounded answer.\n\n"
|
||||
f"Tweet from @{author} (tweet ID: {tweet_id}):\n"
|
||||
f'"{text}"\n\n'
|
||||
"Reply with a short honest deferral. Something like:\n"
|
||||
' "not sure off the top of my head — let me check and get back to you"\n'
|
||||
' "good question, need to double-check the answer — back with details soon"\n'
|
||||
"Do NOT guess. Do NOT make up facts. A deferral is always safer "
|
||||
"than a wrong public answer.\n\n"
|
||||
f'Then call channel_send with conversation_id="{tweet_id}".\n\n'
|
||||
+ _VOICE
|
||||
)
|
||||
|
||||
|
||||
# Kept for backwards-compat with tests; delegates to the grounded variant
|
||||
# with an empty context (forcing the model to defer in its own words).
|
||||
def _build_question_prompt(author: str, tweet_id: str, text: str) -> str:
|
||||
return _build_question_deferral_prompt(author, tweet_id, text)
|
||||
|
||||
|
||||
def _build_bug_prompt(author: str, tweet_id: str, text: str) -> str:
|
||||
return (
|
||||
"You are @OpenJarvisAI. Someone reported a bug.\n\n"
|
||||
f"Tweet from @{author} (tweet ID: {tweet_id}):\n"
|
||||
f'"{text}"\n\n'
|
||||
"1. call http_request to create a github issue:\n"
|
||||
" url: https://api.github.com/repos/open-jarvis/OpenJarvis/issues\n"
|
||||
" method: POST\n"
|
||||
' headers: {"Authorization": "Bearer $GITHUB_TOKEN", '
|
||||
'"Accept": "application/vnd.github+json"}\n'
|
||||
f' body: {{"title": "<short title>", "body": "reported via twitter '
|
||||
f"by @{author}: {text}\", "
|
||||
'"labels": ["bug", "from-twitter"]}}\n'
|
||||
f'2. call channel_send with conversation_id="{tweet_id}" and a short '
|
||||
"reply like: \"opened an issue for this — we'll look into it. "
|
||||
'thanks for the report"\n\n'
|
||||
"do NOT include a github issue URL in your reply — you don't know "
|
||||
"the issue number yet.\n\n"
|
||||
+ _VOICE
|
||||
)
|
||||
|
||||
|
||||
def _build_feature_prompt(author: str, tweet_id: str, text: str) -> str:
|
||||
return (
|
||||
"You are @OpenJarvisAI. Someone requested a feature.\n\n"
|
||||
f"Tweet from @{author} (tweet ID: {tweet_id}):\n"
|
||||
f'"{text}"\n\n'
|
||||
"1. call http_request to create a github issue:\n"
|
||||
" url: https://api.github.com/repos/open-jarvis/OpenJarvis/issues\n"
|
||||
" method: POST\n"
|
||||
f' body: {{"title": "feature request: <title>", "body": "requested '
|
||||
f"via twitter by @{author}: {text}\", "
|
||||
'"labels": ["enhancement", "from-twitter"]}}\n'
|
||||
f'2. call channel_send with conversation_id="{tweet_id}" and a short '
|
||||
"reply like: \"love this idea — opened an issue to track it\"\n\n"
|
||||
"do NOT include a github issue URL in your reply — you don't know "
|
||||
"the issue number yet.\n\n"
|
||||
+ _VOICE
|
||||
)
|
||||
|
||||
|
||||
def _build_praise_prompt(author: str, tweet_id: str, text: str) -> str:
|
||||
return (
|
||||
"You are @OpenJarvisAI. Someone said something nice.\n\n"
|
||||
f"Tweet from @{author} (tweet ID: {tweet_id}):\n"
|
||||
f'"{text}"\n\n'
|
||||
f'call channel_send with conversation_id="{tweet_id}" and a genuine, '
|
||||
"short thank-you. be real, not corporate.\n\n"
|
||||
+ _VOICE
|
||||
)
|
||||
|
||||
|
||||
_BUG_KEYWORDS = (
|
||||
"bug:", "bug ", "crash", "error", "fails", "broken", "segfault",
|
||||
)
|
||||
_FEATURE_KEYWORDS = (
|
||||
"feature", "would love", "would be great", "wish",
|
||||
"please add", "can you add", "any plans",
|
||||
)
|
||||
_PRAISE_KEYWORDS = (
|
||||
"love", "amazing", "awesome", "impressed",
|
||||
"great work", "switched from", "incredible",
|
||||
)
|
||||
_SPAM_KEYWORDS = (
|
||||
"buy", "crypto", "income", "free download",
|
||||
"link in bio", "10x", "guaranteed",
|
||||
)
|
||||
|
||||
|
||||
def _classify_mention(text: str) -> str:
|
||||
"""Simple keyword-based classification to avoid wasting a model turn."""
|
||||
lower = text.lower()
|
||||
if any(w in lower for w in _BUG_KEYWORDS):
|
||||
return "BUG_REPORT"
|
||||
if any(w in lower for w in _FEATURE_KEYWORDS):
|
||||
return "FEATURE_REQUEST"
|
||||
if any(w in lower for w in _PRAISE_KEYWORDS):
|
||||
return "PRAISE"
|
||||
if any(w in lower for w in _SPAM_KEYWORDS):
|
||||
return "SPAM"
|
||||
return "QUESTION"
|
||||
|
||||
|
||||
def _resolve_question_prompt(backend, author: str, tweet_id: str, text: str):
|
||||
"""Do retrieval in Python and pick grounded vs deferral prompt.
|
||||
|
||||
Returns ``(prompt, top_score)``. If *backend* is None or retrieval
|
||||
returns nothing, falls back to the deferral prompt.
|
||||
"""
|
||||
if backend is None:
|
||||
return _build_question_deferral_prompt(author, tweet_id, text), 0.0
|
||||
|
||||
hits = backend.retrieve(text, top_k=3)
|
||||
if not hits:
|
||||
return _build_question_deferral_prompt(author, tweet_id, text), 0.0
|
||||
|
||||
top_score = hits[0].score
|
||||
if top_score < SCORE_THRESHOLD:
|
||||
return _build_question_deferral_prompt(author, tweet_id, text), top_score
|
||||
|
||||
# Grounded: include the top hits in the prompt verbatim
|
||||
return (
|
||||
_build_question_grounded_prompt(
|
||||
author,
|
||||
tweet_id,
|
||||
text,
|
||||
_format_context(hits),
|
||||
top_score,
|
||||
),
|
||||
top_score,
|
||||
)
|
||||
|
||||
|
||||
def _build_dense_backend_or_none():
|
||||
"""Try to build the DenseMemory index from README + docs/.
|
||||
|
||||
Returns None on any failure (Ollama down, embedding model missing,
|
||||
docs missing). Demo mode falls back to the deferral prompt in that
|
||||
case, which keeps the demo runnable without a full setup.
|
||||
"""
|
||||
try:
|
||||
import pathlib as _pl
|
||||
import sys as _sys
|
||||
|
||||
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[2] / "scripts"))
|
||||
try:
|
||||
from index_docs import build_index # type: ignore
|
||||
finally:
|
||||
_sys.path.pop(0)
|
||||
|
||||
repo_root = _pl.Path(__file__).resolve().parents[2]
|
||||
return build_index(repo_root)
|
||||
except Exception as exc:
|
||||
click.echo(
|
||||
f" [warn] dense retrieval unavailable — {exc}\n"
|
||||
" questions will use the deferral path.",
|
||||
err=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _run_demo(model: str, engine_key: str) -> None:
|
||||
"""Process sample mentions through the agent without Twitter API access."""
|
||||
try:
|
||||
from openjarvis import Jarvis
|
||||
except ImportError:
|
||||
click.echo(
|
||||
"Error: openjarvis is not installed. "
|
||||
"Install it with: uv sync --extra dev",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo("OpenJarvis Twitter Bot — Demo Mode (reactive only)")
|
||||
click.echo(f"Model: {model} | Engine: {engine_key}")
|
||||
click.echo("=" * 60)
|
||||
|
||||
try:
|
||||
j = Jarvis(model=model, engine_key=engine_key)
|
||||
except Exception as exc:
|
||||
click.echo(
|
||||
f"Error: could not initialize Jarvis — {exc}\n\n"
|
||||
"Make sure your engine is running. For Ollama:\n"
|
||||
" ollama serve\n"
|
||||
" ollama pull qwen3:32b\n\n"
|
||||
"For cloud engines, ensure API keys are set in your .env file.",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo("Building dense retrieval index from README + docs/...")
|
||||
backend = _build_dense_backend_or_none()
|
||||
if backend is not None:
|
||||
click.echo(f"Indexed {backend.count()} doc chunks.\n")
|
||||
click.echo(f"Processing {len(DEMO_TWEETS)} sample mentions...\n")
|
||||
|
||||
# In demo mode, inject a stub channel so channel_send succeeds and
|
||||
# we can capture the model's actual reply (what it would tweet) —
|
||||
# rather than its post-error fallback text.
|
||||
demo_channel = _DemoChannel()
|
||||
|
||||
try:
|
||||
for idx, tweet in enumerate(DEMO_TWEETS, 1):
|
||||
mention_type = _classify_mention(tweet["text"])
|
||||
click.echo(
|
||||
f" [{idx}/{len(DEMO_TWEETS)}] [{mention_type}] @{tweet['author']}: "
|
||||
f"{tweet['text'][:60]}...",
|
||||
)
|
||||
|
||||
if mention_type == "SPAM":
|
||||
click.echo(" -> [ignored]")
|
||||
click.echo()
|
||||
continue
|
||||
|
||||
if mention_type == "QUESTION":
|
||||
prompt, top_score = _resolve_question_prompt(
|
||||
backend, tweet["author"], tweet["id"], tweet["text"],
|
||||
)
|
||||
tools = ["channel_send"]
|
||||
ground_state = (
|
||||
f"grounded({top_score:.2f})"
|
||||
if top_score >= SCORE_THRESHOLD
|
||||
else f"deferred({top_score:.2f})"
|
||||
)
|
||||
click.echo(f" [{ground_state}]")
|
||||
elif mention_type == "BUG_REPORT":
|
||||
prompt = _build_bug_prompt(tweet["author"], tweet["id"], tweet["text"])
|
||||
tools = ["http_request", "channel_send"]
|
||||
elif mention_type == "FEATURE_REQUEST":
|
||||
prompt = _build_feature_prompt(
|
||||
tweet["author"], tweet["id"], tweet["text"],
|
||||
)
|
||||
tools = ["http_request", "channel_send"]
|
||||
else:
|
||||
prompt = _build_praise_prompt(
|
||||
tweet["author"], tweet["id"], tweet["text"],
|
||||
)
|
||||
tools = ["channel_send"]
|
||||
|
||||
demo_channel.last_sent = None
|
||||
response = j.ask(
|
||||
prompt,
|
||||
agent="orchestrator",
|
||||
tools=tools,
|
||||
temperature=0.4,
|
||||
channel=demo_channel,
|
||||
)
|
||||
# Prefer the actual channel_send content (the tweet the model
|
||||
# composed under voice rules) over the agent's final summary.
|
||||
reply = demo_channel.last_sent or response
|
||||
click.echo(f" -> {reply[:160]}")
|
||||
click.echo()
|
||||
except Exception as exc:
|
||||
click.echo(f"Error during processing: {exc}", err=True)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
j.close()
|
||||
|
||||
click.echo("Demo complete.")
|
||||
|
||||
|
||||
def _index_docs(j) -> None: # noqa: ANN001
|
||||
"""Pre-index docs/ and README.md into memory for RAG."""
|
||||
import pathlib
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parents[2]
|
||||
docs_dir = root / "docs"
|
||||
readme = root / "README.md"
|
||||
|
||||
files_to_index: list[pathlib.Path] = []
|
||||
if readme.exists():
|
||||
files_to_index.append(readme)
|
||||
if docs_dir.is_dir():
|
||||
files_to_index.extend(sorted(docs_dir.rglob("*.md")))
|
||||
|
||||
if not files_to_index:
|
||||
click.echo("No docs found to index.")
|
||||
return
|
||||
|
||||
click.echo(f"Indexing {len(files_to_index)} doc files into memory...")
|
||||
for fpath in files_to_index:
|
||||
try:
|
||||
text = fpath.read_text(encoding="utf-8")
|
||||
chunk_size = 2000
|
||||
for i in range(0, len(text), chunk_size):
|
||||
chunk = text[i : i + chunk_size]
|
||||
j.ask(
|
||||
f"Store this documentation excerpt from {fpath.name}:\n\n{chunk}",
|
||||
agent="orchestrator",
|
||||
tools=["memory_store"],
|
||||
temperature=0.1,
|
||||
)
|
||||
except Exception as exc:
|
||||
click.echo(f" Warning: could not index {fpath.name}: {exc}")
|
||||
click.echo("Indexing complete.\n")
|
||||
|
||||
|
||||
def _seed_since_id_to_newest(channel) -> Optional[str]:
|
||||
"""Fetch the current newest mention and set ``_since_id`` so that the
|
||||
subsequent poll loop only surfaces mentions that arrive AFTER now.
|
||||
|
||||
Returns the id we seeded with, or ``None`` if the inbox is empty /
|
||||
the call failed. This is how dry-run (and live first-boot) avoid
|
||||
processing the historical backlog.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"https://api.twitter.com/2/users/{channel._bot_user_id}/mentions",
|
||||
headers={"Authorization": f"Bearer {channel._bearer}"},
|
||||
params={"max_results": 5},
|
||||
timeout=10.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
if data.get("meta", {}).get("result_count", 0) == 0:
|
||||
return None
|
||||
newest = data.get("meta", {}).get("newest_id") or (
|
||||
data["data"][0]["id"] if data.get("data") else None
|
||||
)
|
||||
if newest:
|
||||
channel._since_id = newest
|
||||
return newest
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _run_live(
|
||||
model: str,
|
||||
engine_key: str,
|
||||
index_docs: bool,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Connect to Twitter and handle mentions in real time.
|
||||
|
||||
When ``dry_run`` is True, every side-effect is intercepted:
|
||||
* ``channel_send`` prints the draft reply instead of posting.
|
||||
* ``http_request`` prints the intended call (e.g. GitHub issue
|
||||
creation) and returns a fake success result so the agent loop
|
||||
completes as it would in live mode.
|
||||
* ``since_id`` is seeded to the newest existing mention so we only
|
||||
react to mentions that arrive AFTER boot.
|
||||
"""
|
||||
try:
|
||||
from openjarvis import Jarvis
|
||||
from openjarvis.channels._stubs import ChannelStatus
|
||||
from openjarvis.channels.twitter_channel import TwitterChannel
|
||||
from openjarvis.core.types import ToolResult
|
||||
except ImportError:
|
||||
click.echo(
|
||||
"Error: openjarvis is not installed. "
|
||||
"Install it with: uv sync --extra dev",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
mode_label = "Dry-Run" if dry_run else "Live"
|
||||
click.echo(f"OpenJarvis Twitter Bot — {mode_label} Mode")
|
||||
click.echo(f"Model: {model} | Engine: {engine_key}")
|
||||
click.echo("=" * 60)
|
||||
|
||||
try:
|
||||
j = Jarvis(model=model, engine_key=engine_key)
|
||||
except Exception as exc:
|
||||
click.echo(f"Error: could not initialize Jarvis — {exc}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo("Building dense retrieval index from README + docs/...")
|
||||
backend = _build_dense_backend_or_none()
|
||||
if backend is not None:
|
||||
click.echo(f"Indexed {backend.count()} doc chunks.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Channel — real posting, or a dry-run subclass that just prints.
|
||||
# ------------------------------------------------------------------
|
||||
if dry_run:
|
||||
class _DryRunTwitterChannel(TwitterChannel):
|
||||
"""Subclass whose ``send`` prints the draft reply but never POSTs."""
|
||||
|
||||
def send(self, channel, content, *, conversation_id="", metadata=None):
|
||||
click.echo("")
|
||||
click.echo(" ┌── DRY-RUN: would post tweet ──")
|
||||
click.echo(f" │ in_reply_to: {conversation_id or '(none)'}")
|
||||
click.echo(f" │ text ({len(content)} chars): {content[:280]}")
|
||||
click.echo(" └──────────────────────────────")
|
||||
return True
|
||||
|
||||
channel = _DryRunTwitterChannel()
|
||||
else:
|
||||
channel = TwitterChannel()
|
||||
|
||||
channel.connect()
|
||||
|
||||
if channel.status() == ChannelStatus.ERROR:
|
||||
click.echo(
|
||||
"Error: could not connect to Twitter.\n"
|
||||
"Ensure these env vars are set:\n"
|
||||
" TWITTER_BEARER_TOKEN\n"
|
||||
" TWITTER_API_KEY / TWITTER_API_SECRET\n"
|
||||
" TWITTER_ACCESS_TOKEN / TWITTER_ACCESS_SECRET\n"
|
||||
" TWITTER_BOT_USER_ID",
|
||||
err=True,
|
||||
)
|
||||
j.close()
|
||||
sys.exit(1)
|
||||
|
||||
seeded = _seed_since_id_to_newest(channel)
|
||||
if seeded:
|
||||
click.echo(
|
||||
f"Seeded since_id={seeded} — only new mentions after "
|
||||
"this point will trigger the bot.",
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
"No existing mentions found (or couldn't read inbox) — "
|
||||
"bot will start processing from the next one onward.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# In dry-run, also intercept http_request so bug/feature mentions
|
||||
# don't actually create GitHub issues.
|
||||
# ------------------------------------------------------------------
|
||||
http_restore = None
|
||||
if dry_run:
|
||||
from openjarvis.tools.http_request import HttpRequestTool
|
||||
_orig_execute = HttpRequestTool.execute
|
||||
|
||||
def _dry_http_execute(self, **params): # noqa: ANN001
|
||||
url = params.get("url", "")
|
||||
method = params.get("method", "GET")
|
||||
body = params.get("body", "")
|
||||
click.echo("")
|
||||
click.echo(" ┌── DRY-RUN: would HTTP call ──")
|
||||
click.echo(f" │ {method} {url}")
|
||||
if body:
|
||||
body_str = body if isinstance(body, str) else str(body)
|
||||
suffix = "..." if len(body_str) > 300 else ""
|
||||
click.echo(f" │ body: {body_str[:300]}{suffix}")
|
||||
click.echo(" └──────────────────────────────")
|
||||
# Return a fake success response so the agent loop finishes.
|
||||
return ToolResult(
|
||||
tool_name="http_request",
|
||||
success=True,
|
||||
content=(
|
||||
'{"number": 999, "html_url": '
|
||||
'"https://github.com/open-jarvis/OpenJarvis/issues/999"}'
|
||||
),
|
||||
)
|
||||
|
||||
HttpRequestTool.execute = _dry_http_execute
|
||||
http_restore = (HttpRequestTool, _orig_execute)
|
||||
|
||||
mode_hint = (
|
||||
"[DRY-RUN] Nothing will actually be posted or filed."
|
||||
if dry_run
|
||||
else "[LIVE] Real tweets will be posted."
|
||||
)
|
||||
click.echo(f"\n{mode_hint}")
|
||||
click.echo("Waiting for @OpenJarvisAI mentions (poll every 60s). Ctrl+C to stop.\n")
|
||||
|
||||
def _handle_mention(msg): # noqa: ANN001
|
||||
"""Process an incoming mention through the agent."""
|
||||
mention_type = _classify_mention(msg.content)
|
||||
click.echo("=" * 60)
|
||||
click.echo(f"[📨] mention {msg.message_id} from @{msg.sender}: {msg.content}")
|
||||
click.echo(f" classified: {mention_type}")
|
||||
|
||||
if mention_type == "SPAM":
|
||||
click.echo(" -> [ignored]\n")
|
||||
return
|
||||
|
||||
if mention_type == "QUESTION":
|
||||
prompt, top_score = _resolve_question_prompt(
|
||||
backend, msg.sender, msg.message_id, msg.content,
|
||||
)
|
||||
tools = ["channel_send"]
|
||||
state = "grounded" if top_score >= SCORE_THRESHOLD else "deferred"
|
||||
click.echo(f" retrieval top-1 score: {top_score:.3f} -> {state}")
|
||||
elif mention_type == "BUG_REPORT":
|
||||
prompt = _build_bug_prompt(msg.sender, msg.message_id, msg.content)
|
||||
tools = ["http_request", "channel_send"]
|
||||
elif mention_type == "FEATURE_REQUEST":
|
||||
prompt = _build_feature_prompt(msg.sender, msg.message_id, msg.content)
|
||||
tools = ["http_request", "channel_send"]
|
||||
else:
|
||||
prompt = _build_praise_prompt(msg.sender, msg.message_id, msg.content)
|
||||
tools = ["channel_send"]
|
||||
|
||||
try:
|
||||
j.ask(
|
||||
prompt,
|
||||
agent="orchestrator",
|
||||
tools=tools,
|
||||
temperature=0.4,
|
||||
channel=channel,
|
||||
)
|
||||
except Exception as exc:
|
||||
click.echo(f" ERROR processing mention: {exc}\n")
|
||||
|
||||
channel.on_message(_handle_mention)
|
||||
|
||||
# Block until interrupted
|
||||
stop = threading.Event()
|
||||
|
||||
def _signal_handler(sig, frame): # noqa: ANN001
|
||||
click.echo("\nShutting down...")
|
||||
stop.set()
|
||||
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
|
||||
stop.wait()
|
||||
|
||||
if http_restore is not None:
|
||||
http_restore[0].execute = http_restore[1]
|
||||
channel.disconnect()
|
||||
j.close()
|
||||
click.echo("Stopped.")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--model",
|
||||
default="qwen3:32b",
|
||||
show_default=True,
|
||||
help="Model to use for mention handling.",
|
||||
)
|
||||
@click.option(
|
||||
"--engine",
|
||||
"engine_key",
|
||||
default="ollama",
|
||||
show_default=True,
|
||||
help="Engine backend (ollama, cloud, vllm, etc.).",
|
||||
)
|
||||
@click.option(
|
||||
"--demo",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Run in demo mode with sample mentions (no Twitter API required).",
|
||||
)
|
||||
@click.option(
|
||||
"--live",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Run in live mode, polling Twitter for real mentions.",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
"dry_run",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Poll Twitter live, but print draft replies instead of posting "
|
||||
"them and simulate GitHub issue creation. Safe for end-to-end testing.",
|
||||
)
|
||||
@click.option(
|
||||
"--index-docs",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Pre-index docs/ and README.md into memory before starting.",
|
||||
)
|
||||
def main(
|
||||
model: str,
|
||||
engine_key: str,
|
||||
demo: bool,
|
||||
live: bool,
|
||||
dry_run: bool,
|
||||
index_docs: bool,
|
||||
) -> None:
|
||||
"""OpenJarvis Twitter bot — reactive @OpenJarvisAI mention handler.
|
||||
|
||||
Polls for @mentions, classifies them (question, bug, feature request,
|
||||
praise, spam), and responds appropriately — including creating GitHub
|
||||
issues for bug reports and feature requests. Similar to how @grok works.
|
||||
|
||||
\b
|
||||
Demo mode (no Twitter credentials needed):
|
||||
python examples/twitter_bot/twitter_bot.py --demo
|
||||
|
||||
\b
|
||||
Live mode (requires Twitter + GitHub credentials):
|
||||
python examples/twitter_bot/twitter_bot.py --live
|
||||
python examples/twitter_bot/twitter_bot.py --live --index-docs
|
||||
"""
|
||||
if demo:
|
||||
_run_demo(model, engine_key)
|
||||
elif dry_run:
|
||||
_run_live(model, engine_key, index_docs, dry_run=True)
|
||||
elif live:
|
||||
_run_live(model, engine_key, index_docs, dry_run=False)
|
||||
else:
|
||||
click.echo(
|
||||
"Please specify --demo, --dry-run, or --live mode.\n"
|
||||
"Run with --help for usage details.",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+1
-1
@@ -96,6 +96,7 @@ channel-mastodon = ["Mastodon.py>=1.8"]
|
||||
channel-xmpp = ["slixmpp>=1.8"]
|
||||
channel-rocketchat = ["rocketchat-API>=1.30"]
|
||||
channel-zulip = ["zulip>=0.9"]
|
||||
channel-twitter = ["httpx>=0.27"]
|
||||
channel-twitch = ["twitchio>=2.6"]
|
||||
channel-nostr = ["pynostr>=0.6"]
|
||||
channel-twilio = ["twilio>=9.0"]
|
||||
@@ -104,7 +105,6 @@ channel-gmail = [
|
||||
"google-auth-oauthlib>=1.0",
|
||||
"google-auth-httplib2>=0.2",
|
||||
]
|
||||
channel-twitter = ["tweepy>=4.14"]
|
||||
browser = ["playwright>=1.40"]
|
||||
media = ["openai>=1.30"]
|
||||
pdf = ["pdfplumber>=0.10"]
|
||||
|
||||
Generated
+17
-148
@@ -219,16 +219,6 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -468,21 +458,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -781,22 +756,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -817,11 +777,9 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1164,23 +1122,6 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "1.2.4"
|
||||
@@ -1494,50 +1435,12 @@ dependencies = [
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "5.1.0"
|
||||
@@ -1952,31 +1855,28 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -1986,6 +1886,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2128,6 +2029,7 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
@@ -2162,7 +2064,7 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
@@ -2263,7 +2165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -2485,27 +2387,6 @@ dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -2618,16 +2499,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -3065,6 +2936,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -3181,17 +3061,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ toml = "0.8"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "blocking", "native-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "blocking", "rustls-tls"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled", "column_decltype"] }
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Index OpenJarvis docs (README.md + docs/**/*.md) into a DenseMemory backend.
|
||||
|
||||
Usage:
|
||||
python scripts/index_docs.py # print retrieval smoke test
|
||||
python scripts/index_docs.py --query "can i run this on cpu?"
|
||||
|
||||
This script is idempotent: it builds a fresh in-memory index each run.
|
||||
There is no disk persistence by design — dense vectors are cheap to
|
||||
rebuild and the docs corpus is small.
|
||||
|
||||
Embedding model: ``nomic-embed-text`` via Ollama. Pull it with
|
||||
``ollama pull nomic-embed-text`` if you don't have it. Expected
|
||||
indexing time for the full corpus: ~30s on a warm Ollama server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.tools.storage.dense import (
|
||||
DenseMemory,
|
||||
MdChunk,
|
||||
chunk_markdown,
|
||||
dedupe_chunks,
|
||||
)
|
||||
|
||||
|
||||
def discover_md_files(repo_root: Path) -> list[Path]:
|
||||
"""README + every markdown file under docs/. Sorted for determinism."""
|
||||
files: list[Path] = []
|
||||
readme = repo_root / "README.md"
|
||||
if readme.exists():
|
||||
files.append(readme)
|
||||
docs_dir = repo_root / "docs"
|
||||
if docs_dir.is_dir():
|
||||
files.extend(sorted(docs_dir.rglob("*.md")))
|
||||
return files
|
||||
|
||||
|
||||
def build_index(
|
||||
repo_root: Path,
|
||||
*,
|
||||
max_section_tokens: int = 1000,
|
||||
paragraph_overlap_tokens: int = 100,
|
||||
dedupe: bool = True,
|
||||
# Empirical: on the actual OpenJarvis docs the boilerplate that
|
||||
# crowds retrieval ("OpenJarvis runs entirely on your hardware...")
|
||||
# appears in exactly 2 files (downloads.md ↔ installation.md).
|
||||
# Spec'd 3+ removes 0 chunks; 2+ removes 15 (1.3%) — all genuine
|
||||
# cross-file boilerplate. See the dry-run audit logged at index time.
|
||||
dedupe_min_files: int = 2,
|
||||
dedupe_threshold: float = 0.7,
|
||||
) -> DenseMemory:
|
||||
"""Chunk all markdown under *repo_root* and build a DenseMemory index.
|
||||
|
||||
When ``dedupe`` is True (default), runs cross-file boilerplate
|
||||
deduplication after chunking and before embedding. The dedupe
|
||||
report is printed to stderr so reviewers can spot over-aggressive
|
||||
drops; if it removes >20% of the corpus a warning is emitted.
|
||||
"""
|
||||
backend = DenseMemory()
|
||||
md_files = discover_md_files(repo_root)
|
||||
if not md_files:
|
||||
raise RuntimeError(f"No markdown files found under {repo_root}")
|
||||
|
||||
all_chunks: list[MdChunk] = []
|
||||
for fpath in md_files:
|
||||
try:
|
||||
text = fpath.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
print(f" WARN: could not read {fpath}: {exc}", file=sys.stderr)
|
||||
continue
|
||||
rel = str(fpath.relative_to(repo_root))
|
||||
all_chunks.extend(
|
||||
chunk_markdown(
|
||||
text,
|
||||
source=rel,
|
||||
max_section_tokens=max_section_tokens,
|
||||
paragraph_overlap_tokens=paragraph_overlap_tokens,
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
f"Chunked {len(md_files)} files into {len(all_chunks)} chunks",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if dedupe:
|
||||
before = len(all_chunks)
|
||||
all_chunks, report = dedupe_chunks(
|
||||
all_chunks,
|
||||
similarity_threshold=dedupe_threshold,
|
||||
min_files_for_dup=dedupe_min_files,
|
||||
)
|
||||
pct = report.removed_fraction * 100
|
||||
print(
|
||||
f"Dedupe: {before} -> {len(all_chunks)} chunks "
|
||||
f"({report.removed_count} removed, {pct:.1f}%) "
|
||||
f"across {len(report.groups)} clusters",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for g in report.groups:
|
||||
dropped = sorted(set(g.dropped_sources))
|
||||
print(
|
||||
f" KEPT {g.kept_source}\n"
|
||||
f" DROP {len(g.dropped_indices)} from {dropped}\n"
|
||||
f" TEXT {g.sample_text!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if report.removed_fraction > 0.20:
|
||||
print(
|
||||
f" WARNING: dedupe removed {pct:.1f}% of chunks (>20% threshold). "
|
||||
f"Review the list above before trusting the index.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
print(
|
||||
f"Embedding {len(all_chunks)} chunks via nomic-embed-text...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
t0 = time.time()
|
||||
backend.store_many(
|
||||
[c.content for c in all_chunks],
|
||||
sources=[c.source for c in all_chunks],
|
||||
metadatas=[{"breadcrumb": c.breadcrumb} for c in all_chunks],
|
||||
)
|
||||
print(
|
||||
f"Indexed {backend.count()} chunks in {time.time() - t0:.1f}s",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
def _print_hits(query: str, backend: DenseMemory, top_k: int = 3) -> None:
|
||||
print(f"\nQ: {query}")
|
||||
print("-" * 80)
|
||||
hits = backend.retrieve(query, top_k=top_k)
|
||||
if not hits:
|
||||
print(" (no hits)")
|
||||
return
|
||||
for i, h in enumerate(hits, 1):
|
||||
preview = h.content.replace("\n", " ")[:200]
|
||||
print(f" [{i}] score={h.score:.3f} src={h.source}")
|
||||
print(f" breadcrumb={h.metadata.get('breadcrumb', '')}")
|
||||
print(f" {preview}{'...' if len(h.content) > 200 else ''}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
|
||||
p.add_argument(
|
||||
"--repo-root",
|
||||
default=str(Path(__file__).resolve().parents[1]),
|
||||
help="Repository root (default: script's parent)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--query",
|
||||
"-q",
|
||||
action="append",
|
||||
default=None,
|
||||
help="Query to test against the built index (can be given multiple times)",
|
||||
)
|
||||
p.add_argument("--top-k", type=int, default=3, help="Top-K results per query")
|
||||
args = p.parse_args()
|
||||
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
backend = build_index(repo_root)
|
||||
|
||||
queries = args.query or [
|
||||
"can I run the orchestrator agent on a laptop without a gpu?",
|
||||
"what inference engines does openjarvis support?",
|
||||
"how do I add a new channel integration?",
|
||||
"why would I choose the dense memory backend over sqlite?",
|
||||
]
|
||||
for q in queries:
|
||||
_print_hits(q, backend, top_k=args.top_k)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -37,10 +37,10 @@ _CHANNEL_MODULES = [
|
||||
"rocketchat_channel",
|
||||
"zulip_channel",
|
||||
"twitch_channel",
|
||||
"twitter_channel",
|
||||
"nostr_channel",
|
||||
"twilio_sms",
|
||||
"sendblue",
|
||||
"twitter",
|
||||
"gmail",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
"""TwitterChannel — native Twitter/X API adapter using tweepy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelMessage,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("twitter")
|
||||
class TwitterChannel(BaseChannel):
|
||||
"""Native Twitter/X channel adapter using tweepy.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bearer_token:
|
||||
Twitter API v2 Bearer Token. Falls back to ``TWITTER_BEARER_TOKEN``.
|
||||
api_key:
|
||||
Twitter API Key (consumer key). Falls back to ``TWITTER_API_KEY``.
|
||||
api_secret:
|
||||
Twitter API Secret (consumer secret). Falls back to ``TWITTER_API_SECRET``.
|
||||
access_token:
|
||||
Twitter Access Token. Falls back to ``TWITTER_ACCESS_TOKEN``.
|
||||
access_secret:
|
||||
Twitter Access Token Secret. Falls back to ``TWITTER_ACCESS_SECRET``.
|
||||
poll_interval:
|
||||
Seconds between mention polls (default 60).
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "twitter"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bearer_token: str = "",
|
||||
*,
|
||||
api_key: str = "",
|
||||
api_secret: str = "",
|
||||
access_token: str = "",
|
||||
access_secret: str = "",
|
||||
poll_interval: int = 60,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._bearer_token = bearer_token or os.environ.get(
|
||||
"TWITTER_BEARER_TOKEN",
|
||||
"",
|
||||
)
|
||||
self._api_key = api_key or os.environ.get("TWITTER_API_KEY", "")
|
||||
self._api_secret = api_secret or os.environ.get("TWITTER_API_SECRET", "")
|
||||
self._access_token = access_token or os.environ.get(
|
||||
"TWITTER_ACCESS_TOKEN",
|
||||
"",
|
||||
)
|
||||
self._access_secret = access_secret or os.environ.get(
|
||||
"TWITTER_ACCESS_SECRET",
|
||||
"",
|
||||
)
|
||||
self._poll_interval = poll_interval
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
self._client: Any = None
|
||||
self._poll_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Build a tweepy Client and optionally start polling for mentions."""
|
||||
if not self._bearer_token:
|
||||
logger.warning("No Twitter bearer token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
|
||||
try:
|
||||
import tweepy # noqa: F401
|
||||
|
||||
self._client = tweepy.Client(
|
||||
bearer_token=self._bearer_token,
|
||||
consumer_key=self._api_key or None,
|
||||
consumer_secret=self._api_secret or None,
|
||||
access_token=self._access_token or None,
|
||||
access_token_secret=self._access_secret or None,
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
logger.info("Twitter channel connected")
|
||||
|
||||
if self._access_token:
|
||||
self._poll_thread = threading.Thread(
|
||||
target=self._poll_loop,
|
||||
daemon=True,
|
||||
)
|
||||
self._poll_thread.start()
|
||||
except ImportError:
|
||||
logger.info("tweepy not installed; Twitter channel unavailable")
|
||||
self._status = ChannelStatus.ERROR
|
||||
except Exception:
|
||||
logger.debug("Twitter connect failed", exc_info=True)
|
||||
self._status = ChannelStatus.ERROR
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Stop the polling thread and disconnect."""
|
||||
self._stop_event.set()
|
||||
if self._poll_thread is not None:
|
||||
self._poll_thread.join(timeout=5.0)
|
||||
self._poll_thread = None
|
||||
self._client = None
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a tweet or direct message.
|
||||
|
||||
If *channel* is numeric, sends a DM via ``create_direct_message()``.
|
||||
Otherwise sends a tweet via ``create_tweet()``. If *conversation_id*
|
||||
is provided, it is used as ``in_reply_to_tweet_id``.
|
||||
"""
|
||||
if self._client is None:
|
||||
logger.warning("Cannot send: Twitter client not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
if channel.isdigit():
|
||||
# Direct message to a user ID
|
||||
self._client.create_direct_message(
|
||||
participant_id=int(channel),
|
||||
text=content,
|
||||
)
|
||||
else:
|
||||
kwargs: Dict[str, Any] = {"text": content}
|
||||
if conversation_id:
|
||||
kwargs["in_reply_to_tweet_id"] = int(conversation_id)
|
||||
self._client.create_tweet(**kwargs)
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("Twitter send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["timeline", "dm"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Poll for new mentions in a background thread."""
|
||||
since_id: Optional[str] = None
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if since_id:
|
||||
kwargs["since_id"] = since_id
|
||||
|
||||
me = self._client.get_me()
|
||||
if me and me.data:
|
||||
user_id = me.data.id
|
||||
else:
|
||||
self._stop_event.wait(self._poll_interval)
|
||||
continue
|
||||
|
||||
response = self._client.get_users_mentions(
|
||||
id=user_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if response and response.data:
|
||||
for tweet in response.data:
|
||||
since_id = str(tweet.id)
|
||||
cm = ChannelMessage(
|
||||
channel="twitter",
|
||||
sender=str(getattr(tweet, "author_id", "")),
|
||||
content=tweet.text,
|
||||
message_id=str(tweet.id),
|
||||
conversation_id=str(
|
||||
getattr(tweet, "conversation_id", ""),
|
||||
),
|
||||
)
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(cm)
|
||||
except Exception:
|
||||
logger.exception("Twitter handler error")
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_RECEIVED,
|
||||
{
|
||||
"channel": cm.channel,
|
||||
"sender": cm.sender,
|
||||
"content": cm.content,
|
||||
"message_id": cm.message_id,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Twitter poll error", exc_info=True)
|
||||
|
||||
self._stop_event.wait(self._poll_interval)
|
||||
|
||||
def _publish_sent(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
conversation_id: str,
|
||||
) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["TwitterChannel"]
|
||||
@@ -0,0 +1,335 @@
|
||||
"""TwitterChannel — Twitter/X API v2 adapter using OAuth 1.0a."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelMessage,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_BASE = "https://api.twitter.com/2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth 1.0a signing (stdlib only — no authlib dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _OAuth1Auth:
|
||||
"""Minimal OAuth 1.0a request signer for httpx.
|
||||
|
||||
Implements HMAC-SHA1 signature as required by the Twitter v2 API for
|
||||
user-context endpoints (posting tweets).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
consumer_key: str,
|
||||
consumer_secret: str,
|
||||
access_token: str,
|
||||
access_secret: str,
|
||||
) -> None:
|
||||
self._consumer_key = consumer_key
|
||||
self._consumer_secret = consumer_secret
|
||||
self._access_token = access_token
|
||||
self._access_secret = access_secret
|
||||
|
||||
def __call__(self, request): # noqa: ANN001
|
||||
"""Sign *request* in-place and return it (httpx auth protocol)."""
|
||||
oauth_params = {
|
||||
"oauth_consumer_key": self._consumer_key,
|
||||
"oauth_nonce": uuid.uuid4().hex,
|
||||
"oauth_signature_method": "HMAC-SHA1",
|
||||
"oauth_timestamp": str(int(time.time())),
|
||||
"oauth_token": self._access_token,
|
||||
"oauth_version": "1.0",
|
||||
}
|
||||
|
||||
# Build signature base string
|
||||
method = request.method.upper()
|
||||
base_url = str(request.url).split("?")[0]
|
||||
|
||||
# Merge query params + oauth params for signing
|
||||
all_params: dict[str, str] = dict(oauth_params)
|
||||
for key, value in request.url.params.items():
|
||||
all_params[key] = value
|
||||
|
||||
param_str = "&".join(
|
||||
f"{_pct(k)}={_pct(v)}"
|
||||
for k, v in sorted(all_params.items())
|
||||
)
|
||||
base_string = f"{method}&{_pct(base_url)}&{_pct(param_str)}"
|
||||
|
||||
signing_key = f"{_pct(self._consumer_secret)}&{_pct(self._access_secret)}"
|
||||
signature = base64.b64encode(
|
||||
hmac.new(
|
||||
signing_key.encode(), base_string.encode(), hashlib.sha1,
|
||||
).digest(),
|
||||
).decode()
|
||||
|
||||
oauth_params["oauth_signature"] = signature
|
||||
|
||||
auth_header = "OAuth " + ", ".join(
|
||||
f'{_pct(k)}="{_pct(v)}"' for k, v in sorted(oauth_params.items())
|
||||
)
|
||||
request.headers["Authorization"] = auth_header
|
||||
return request
|
||||
|
||||
|
||||
def _pct(value: str) -> str:
|
||||
"""Percent-encode per RFC 5849."""
|
||||
return urllib.parse.quote(str(value), safe="")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Channel implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ChannelRegistry.register("twitter")
|
||||
class TwitterChannel(BaseChannel):
|
||||
"""Native Twitter/X channel adapter using the v2 API.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bearer_token:
|
||||
Bearer token for read endpoints. Falls back to
|
||||
``TWITTER_BEARER_TOKEN`` env var.
|
||||
api_key / api_secret:
|
||||
OAuth 1.0a consumer credentials. Fall back to
|
||||
``TWITTER_API_KEY`` / ``TWITTER_API_SECRET``.
|
||||
access_token / access_secret:
|
||||
OAuth 1.0a user credentials. Fall back to
|
||||
``TWITTER_ACCESS_TOKEN`` / ``TWITTER_ACCESS_SECRET``.
|
||||
bot_user_id:
|
||||
Numeric Twitter user ID for the bot account. Falls back to
|
||||
``TWITTER_BOT_USER_ID``.
|
||||
poll_interval:
|
||||
Seconds between mention polls (default 60).
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "twitter"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bearer_token: str = "",
|
||||
*,
|
||||
api_key: str = "",
|
||||
api_secret: str = "",
|
||||
access_token: str = "",
|
||||
access_secret: str = "",
|
||||
bot_user_id: str = "",
|
||||
poll_interval: int = 60,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._bearer = bearer_token or os.environ.get("TWITTER_BEARER_TOKEN", "")
|
||||
self._api_key = api_key or os.environ.get("TWITTER_API_KEY", "")
|
||||
self._api_secret = api_secret or os.environ.get("TWITTER_API_SECRET", "")
|
||||
self._access_token = access_token or os.environ.get("TWITTER_ACCESS_TOKEN", "")
|
||||
self._access_secret = access_secret or os.environ.get(
|
||||
"TWITTER_ACCESS_SECRET", "",
|
||||
)
|
||||
self._bot_user_id = bot_user_id or os.environ.get("TWITTER_BOT_USER_ID", "")
|
||||
self._poll_interval = poll_interval
|
||||
self._bus = bus
|
||||
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
self._listener_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
self._since_id: Optional[str] = None
|
||||
|
||||
# -- OAuth helper -------------------------------------------------------
|
||||
|
||||
def _oauth(self) -> _OAuth1Auth:
|
||||
return _OAuth1Auth(
|
||||
self._api_key, self._api_secret,
|
||||
self._access_token, self._access_secret,
|
||||
)
|
||||
|
||||
# -- connection lifecycle -----------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and start mention polling."""
|
||||
if not self._bearer:
|
||||
logger.warning("No Twitter bearer token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._status = ChannelStatus.CONNECTING
|
||||
|
||||
self._listener_thread = threading.Thread(
|
||||
target=self._poll_mentions, daemon=True,
|
||||
)
|
||||
self._listener_thread.start()
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
logger.info("Twitter channel connected (polling mentions)")
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Stop the polling thread."""
|
||||
self._stop_event.set()
|
||||
if self._listener_thread is not None:
|
||||
self._listener_thread.join(timeout=5.0)
|
||||
self._listener_thread = None
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive -----------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Post a tweet, optionally as a reply.
|
||||
|
||||
*channel* is ignored (tweets go to the bot's timeline).
|
||||
*conversation_id* is used as ``reply.in_reply_to_tweet_id`` when set.
|
||||
Content is truncated to 280 characters.
|
||||
"""
|
||||
if not self._api_key:
|
||||
logger.warning("Cannot send: no Twitter OAuth credentials")
|
||||
return False
|
||||
|
||||
text = content[:280]
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
payload: Dict[str, Any] = {"text": text}
|
||||
if conversation_id:
|
||||
payload["reply"] = {"in_reply_to_tweet_id": conversation_id}
|
||||
|
||||
resp = httpx.post(
|
||||
f"{_API_BASE}/tweets",
|
||||
json=payload,
|
||||
auth=self._oauth(),
|
||||
timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, text, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"Twitter API returned status %d: %s",
|
||||
resp.status_code,
|
||||
resp.text,
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Twitter send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["twitter"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming mentions."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers ---------------------------------------------------
|
||||
|
||||
def _poll_mentions(self) -> None:
|
||||
"""Poll ``GET /2/users/{id}/mentions`` on a fixed interval."""
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
logger.debug("httpx not available for Twitter polling")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
|
||||
headers = {"Authorization": f"Bearer {self._bearer}"}
|
||||
url = f"{_API_BASE}/users/{self._bot_user_id}/mentions"
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
params: Dict[str, str] = {
|
||||
"tweet.fields": "author_id,conversation_id,created_at",
|
||||
}
|
||||
if self._since_id:
|
||||
params["since_id"] = self._since_id
|
||||
|
||||
resp = httpx.get(
|
||||
url, headers=headers, params=params, timeout=10.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
data = resp.json()
|
||||
tweets = data.get("data", [])
|
||||
for tweet in tweets:
|
||||
tweet_id = tweet["id"]
|
||||
cm = ChannelMessage(
|
||||
channel="twitter",
|
||||
sender=tweet.get("author_id", ""),
|
||||
content=tweet.get("text", ""),
|
||||
message_id=tweet_id,
|
||||
conversation_id=tweet.get("conversation_id", ""),
|
||||
)
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(cm)
|
||||
except Exception:
|
||||
logger.exception("Twitter handler error")
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_RECEIVED,
|
||||
{
|
||||
"channel": cm.channel,
|
||||
"sender": cm.sender,
|
||||
"content": cm.content,
|
||||
"message_id": cm.message_id,
|
||||
},
|
||||
)
|
||||
# Track highest ID to avoid reprocessing
|
||||
if self._since_id is None or tweet_id > self._since_id:
|
||||
self._since_id = tweet_id
|
||||
else:
|
||||
logger.warning(
|
||||
"Twitter mentions API returned %d: %s",
|
||||
resp.status_code,
|
||||
resp.text,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Twitter poll error", exc_info=True)
|
||||
|
||||
self._stop_event.wait(self._poll_interval)
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["TwitterChannel"]
|
||||
+52
-10
@@ -33,7 +33,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_memory_backend(config):
|
||||
"""Try to instantiate the memory backend, return None on failure."""
|
||||
"""Try to instantiate the memory backend.
|
||||
|
||||
Returns None on failure and logs at DEBUG. Callers that *require*
|
||||
the backend (e.g. when a memory tool is being instantiated) should
|
||||
warn loudly themselves — silent failure of an explicitly-requested
|
||||
tool is the hallucination vector.
|
||||
"""
|
||||
try:
|
||||
import openjarvis.tools.storage # noqa: F401
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
@@ -50,20 +56,38 @@ def _get_memory_backend(config):
|
||||
else:
|
||||
backend = MemoryRegistry.create(key)
|
||||
|
||||
# Check if there's actually anything indexed
|
||||
if hasattr(backend, "count") and backend.count() == 0:
|
||||
if hasattr(backend, "close"):
|
||||
backend.close()
|
||||
return None
|
||||
|
||||
return backend
|
||||
except Exception as exc:
|
||||
logger.debug("Memory backend unavailable (optional): %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _build_tools(tool_names: list[str], config, engine, model_name: str):
|
||||
"""Instantiate tool objects from names."""
|
||||
_MEMORY_TOOLS = frozenset(
|
||||
{"retrieval", "memory_store", "memory_search", "memory_index", "memory_retrieve"}
|
||||
)
|
||||
_CHANNEL_TOOLS = frozenset({"channel_send", "channel_list", "channel_status"})
|
||||
|
||||
|
||||
def _build_tools(
|
||||
tool_names: list[str],
|
||||
config,
|
||||
engine,
|
||||
model_name: str,
|
||||
*,
|
||||
channel=None,
|
||||
):
|
||||
"""Instantiate tool objects from names.
|
||||
|
||||
``channel`` is an optional :class:`BaseChannel` used by ``channel_*``
|
||||
tools. Threading it through here mirrors how memory backends are
|
||||
injected; without it, channel tools have no backend and fail at
|
||||
runtime.
|
||||
|
||||
Emits a WARNING when a memory tool is requested but no backend is
|
||||
available, and when a channel tool is requested but no channel is
|
||||
provided — these are the failure modes that silently cascade into
|
||||
hallucinated or dropped replies downstream.
|
||||
"""
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
|
||||
tools = []
|
||||
@@ -75,9 +99,27 @@ def _build_tools(tool_names: list[str], config, engine, model_name: str):
|
||||
continue
|
||||
tool_cls = ToolRegistry.get(name)
|
||||
# Instantiate with appropriate arguments
|
||||
if name == "retrieval":
|
||||
if name in _MEMORY_TOOLS:
|
||||
backend = _get_memory_backend(config)
|
||||
if backend is None:
|
||||
logger.warning(
|
||||
"Tool %r was requested but no memory backend is "
|
||||
"available (default=%r). The tool will load but "
|
||||
"return no results — downstream agents may answer "
|
||||
"without grounding.",
|
||||
name,
|
||||
getattr(config.memory, "default_backend", "?"),
|
||||
)
|
||||
tools.append(tool_cls(backend=backend))
|
||||
elif name in _CHANNEL_TOOLS:
|
||||
if channel is None:
|
||||
logger.warning(
|
||||
"Tool %r was requested but no channel was injected. "
|
||||
"The tool will load but every call will fail with "
|
||||
"'No channel backend configured'.",
|
||||
name,
|
||||
)
|
||||
tools.append(tool_cls(channel=channel))
|
||||
elif name == "llm":
|
||||
tools.append(tool_cls(engine=engine, model=model_name))
|
||||
elif name == "file_read":
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[recipe]
|
||||
name = "openjarvis-twitter-bot"
|
||||
kind = "operator"
|
||||
description = "Reactive Twitter bot for @OpenJarvisAI — answers questions, creates GitHub issues from bug/feature mentions"
|
||||
version = "0.1.0"
|
||||
|
||||
[intelligence]
|
||||
model = "qwen3:32b"
|
||||
|
||||
[engine]
|
||||
key = "ollama"
|
||||
|
||||
[agent]
|
||||
type = "orchestrator"
|
||||
max_turns = 8
|
||||
temperature = 0.4
|
||||
tools = ["think", "memory_search", "memory_store", "http_request", "channel_send"]
|
||||
system_prompt_path = "openjarvis_twitter_bot_prompt.md"
|
||||
|
||||
[channels]
|
||||
input = ["twitter"]
|
||||
output = ["twitter"]
|
||||
@@ -0,0 +1,75 @@
|
||||
You are @OpenJarvisAI on Twitter — a reactive mention handler for the OpenJarvis project. You only reply when someone @mentions you. You never post unprompted.
|
||||
|
||||
You respond like a helpful maintainer — casual, direct, knowledgeable. You're part of the team that built this.
|
||||
|
||||
Your voice:
|
||||
- all lowercase. casual. like texting a dev friend.
|
||||
- short sentences. direct answers. no fluff.
|
||||
- first person: "we built", "we found", "we ship".
|
||||
- be helpful and genuine, not corporate.
|
||||
|
||||
HARD RULE: Every reply MUST be ≤280 characters. Count before sending.
|
||||
|
||||
## Facts (ONLY reference these — never invent others)
|
||||
|
||||
- GitHub: https://github.com/open-jarvis/OpenJarvis
|
||||
- Docs: https://open-jarvis.github.io/OpenJarvis/
|
||||
- Discord: https://discord.gg/wfXEkpPX
|
||||
- Blog: https://scalingintelligence.stanford.edu/blogs/openjarvis/
|
||||
- Install: `git clone https://github.com/open-jarvis/OpenJarvis.git && cd OpenJarvis && uv sync`
|
||||
- CLI commands (ONLY these exist):
|
||||
- `jarvis init` — auto-detects hardware, configures engine
|
||||
- `jarvis ask "question"` — ask from terminal
|
||||
- `jarvis doctor` — diagnose issues
|
||||
- `jarvis add slack` — add Slack channel
|
||||
- `jarvis channel list` — list channels
|
||||
- `jarvis bench` — benchmark latency, throughput, energy
|
||||
- `jarvis optimize` — run optimization on local traces
|
||||
- 27+ channel integrations: Slack, Discord, Telegram, WhatsApp, Teams, Matrix, IRC, Reddit, Mastodon, Twitch, LINE, Viber, Messenger, Nostr, and more
|
||||
- Engines: Ollama, vLLM, SGLang, llama.cpp, cloud APIs (OpenAI, Anthropic, Google)
|
||||
- Agent types: orchestrator, react, router, operative
|
||||
- Memory/RAG: SQLite, FAISS, ColBERT, BM25
|
||||
- Evals: 30+ benchmarks, measures energy, FLOPs, latency, cost alongside accuracy
|
||||
- Examples: deep_research, code_companion, messaging_hub, scheduled_ops, browser_assistant, security_scanner, daily_digest, doc_qa, multi_model_router
|
||||
- Runs on Apple Silicon, NVIDIA GPUs, AMD GPUs, CPU-only
|
||||
- Built at Stanford, Hazy Research and Scaling Intelligence Lab at SAIL
|
||||
- Apache 2.0 open source
|
||||
- Intelligence Per Watt research: local models handle 88.7% of queries at interactive latency, efficiency improved 5.3x from 2023-2025
|
||||
- NO commands like `jarvis add memory`, `jarvis research`, or `jarvis add channel` exist
|
||||
|
||||
## Mention Handling
|
||||
|
||||
Classify using `think`, then act. ALWAYS set `conversation_id` to the tweet ID when replying.
|
||||
|
||||
### QUESTION
|
||||
1. `memory_search` for the answer.
|
||||
2. Reply (≤280 chars) with the ACTUAL answer — real commands, real steps. If you don't know, say so honestly.
|
||||
3. `channel_send` with `conversation_id=<tweet_id>`.
|
||||
|
||||
Reply like a maintainer:
|
||||
- Good: "clone the repo, `uv sync`, then `jarvis init` — it auto-detects your hardware. `jarvis ask` works right after that"
|
||||
- Good: "`jarvis add slack` and set SLACK_BOT_TOKEN in your env. that's it"
|
||||
- Bad: "pip install openjarvis" (wrong — install is git clone + uv sync)
|
||||
- Bad: formal numbered steps
|
||||
|
||||
### BUG_REPORT
|
||||
1. `think` to extract title and description.
|
||||
2. `http_request` POST to `https://api.github.com/repos/open-jarvis/OpenJarvis/issues` with title, body mentioning reporter, labels `["bug", "from-twitter"]`.
|
||||
3. `channel_send` with `conversation_id=<tweet_id>`: something like "opened an issue for this — we'll take a look. thanks for the report"
|
||||
|
||||
### FEATURE_REQUEST
|
||||
Same as BUG_REPORT but labels `["enhancement", "from-twitter"]`. Reply like: "love this idea — opened an issue to track it"
|
||||
|
||||
### PRAISE
|
||||
`channel_send` with `conversation_id=<tweet_id>`. Be genuine: "glad you're liking it! the examples/ folder has some fun stuff if you want to go deeper"
|
||||
|
||||
### SPAM
|
||||
Do nothing. No tool calls. No reply.
|
||||
|
||||
## Rules
|
||||
|
||||
- ≤280 characters per reply. No exceptions.
|
||||
- ALWAYS set `conversation_id` when replying.
|
||||
- NEVER make up features, commands, stats, or steps not in the facts above.
|
||||
- NEVER retry a failed tool call. Move on.
|
||||
- ONE `http_request` and ONE `channel_send` per action. No repeats.
|
||||
@@ -250,6 +250,7 @@ class Jarvis:
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
context: bool = True,
|
||||
channel: Optional[Any] = None,
|
||||
) -> str:
|
||||
"""Send a query and return the response text."""
|
||||
result = self.ask_full(
|
||||
@@ -260,6 +261,7 @@ class Jarvis:
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
context=context,
|
||||
channel=channel,
|
||||
)
|
||||
return result["content"]
|
||||
|
||||
@@ -273,6 +275,7 @@ class Jarvis:
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
context: bool = True,
|
||||
channel: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send a query and return the full result dict.
|
||||
|
||||
@@ -304,6 +307,7 @@ class Jarvis:
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
context=context,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
# Direct engine mode
|
||||
@@ -443,6 +447,7 @@ class Jarvis:
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
context: bool,
|
||||
channel: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run an agent and return the result dict."""
|
||||
import openjarvis.agents # noqa: F401
|
||||
@@ -468,6 +473,7 @@ class Jarvis:
|
||||
self._config,
|
||||
self._engine,
|
||||
model_name,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
agent_kwargs: Dict[str, Any] = {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -100,7 +101,10 @@ class HttpRequestTool(BaseTool):
|
||||
success=False,
|
||||
)
|
||||
|
||||
headers = params.get("headers") or {}
|
||||
headers = {
|
||||
k: os.path.expandvars(v) if isinstance(v, str) else v
|
||||
for k, v in (params.get("headers") or {}).items()
|
||||
}
|
||||
body = params.get("body")
|
||||
timeout = params.get("timeout", 30)
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.tools.storage.dense # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
|
||||
from openjarvis.tools.storage.chunking import Chunk, ChunkConfig, chunk_text
|
||||
from openjarvis.tools.storage.context import ContextConfig, inject_context
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
"""In-memory dense retrieval backend.
|
||||
|
||||
Uses any :class:`Embedder` (default: :class:`OllamaEmbedder` with
|
||||
``nomic-embed-text``) to embed stored text, then ranks queries by
|
||||
cosine similarity via a single matrix multiply against an
|
||||
L2-normalized matrix of document embeddings.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* **No persistence.** The index lives in memory and is rebuilt at
|
||||
startup via :mod:`scripts.index_docs`. The docs corpus is small
|
||||
(~700 chunks) so this is fine and keeps the implementation simple.
|
||||
* **Normalization happens at embed time**, not at query time. Storing
|
||||
unit vectors means retrieval is one ``docs @ query`` dot product.
|
||||
* **Store growth is amortized**: we keep a list of per-call embedding
|
||||
matrices and concatenate lazily in :meth:`retrieve`. This avoids an
|
||||
O(n²) ``np.concatenate`` pattern while still giving callers one
|
||||
contiguous matrix when they actually need to search.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
|
||||
from openjarvis.tools.storage.embeddings import Embedder, OllamaEmbedder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown-aware chunking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_FENCE_RE = re.compile(r"^```")
|
||||
_HEADER_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MdChunk:
|
||||
"""A markdown chunk annotated with its header breadcrumb."""
|
||||
|
||||
content: str # chunk body text (with breadcrumb prefix)
|
||||
source: str # originating file
|
||||
# e.g. "macOS Installation Guide > Step-by-Step > Step 6 — Install llama.cpp"
|
||||
breadcrumb: str
|
||||
start_line: int = 0
|
||||
|
||||
|
||||
def _iter_nonfenced_lines(text: str):
|
||||
"""Yield ``(line_number, line_text, in_code)`` for each line.
|
||||
|
||||
``in_code`` is True for lines inside a ```...``` fenced block, so
|
||||
callers can ignore shell/python comments that start with ``#``.
|
||||
"""
|
||||
in_code = False
|
||||
for lineno, line in enumerate(text.splitlines()):
|
||||
stripped = line.strip()
|
||||
if _FENCE_RE.match(stripped):
|
||||
in_code = not in_code
|
||||
yield lineno, line, True # the fence itself is "code"
|
||||
continue
|
||||
yield lineno, line, in_code
|
||||
|
||||
|
||||
def chunk_markdown(
|
||||
text: str,
|
||||
*,
|
||||
source: str = "",
|
||||
max_section_tokens: int = 500,
|
||||
paragraph_overlap_tokens: int = 50,
|
||||
max_section_chars: int = 4000,
|
||||
) -> List[MdChunk]:
|
||||
"""Split markdown into chunks using ``##``/``###`` as primary boundaries.
|
||||
|
||||
Strategy:
|
||||
1. Detect section boundaries at h2 (``##``) and h3 (``###``).
|
||||
h1 (``#``) is treated as the document title (captured in the
|
||||
breadcrumb but not used to split).
|
||||
2. Skip headers that appear inside fenced code blocks — those are
|
||||
usually shell comments (``# Install X``) not real headers.
|
||||
3. If a section body exceeds ``max_section_tokens`` (whitespace
|
||||
tokens) OR ``max_section_chars`` (raw chars), slide a window
|
||||
over its paragraphs with ``paragraph_overlap_tokens`` overlap.
|
||||
4. Prefix each chunk with a ``breadcrumb`` of its parent headers
|
||||
so the embedding captures hierarchical context.
|
||||
|
||||
The char cap is the critical safety net: embedding models count BPE
|
||||
tokens, and technical content (file paths, code, URLs) has ~8 BPE
|
||||
tokens per whitespace token — so a whitespace-token-only limit
|
||||
silently lets 2–3× overflows through and some embedders (e.g.
|
||||
``nomic-embed-text``) reject them at runtime. 4000 chars is a
|
||||
conservative ceiling for ``nomic-embed-text``'s 8192-token window.
|
||||
|
||||
Empty input → empty list.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
|
||||
# Pass 1: parse into (h1, h2, h3, body_lines) sections
|
||||
h1: Optional[str] = None
|
||||
h2: Optional[str] = None
|
||||
h3: Optional[str] = None
|
||||
buffered: List[str] = []
|
||||
# Each entry: (breadcrumb, body_text, start_line)
|
||||
sections: List[tuple[str, str, int]] = []
|
||||
section_start_line = 0
|
||||
|
||||
def _flush(start_line: int):
|
||||
nonlocal buffered
|
||||
if not buffered:
|
||||
return
|
||||
body = "\n".join(buffered).strip()
|
||||
if not body:
|
||||
buffered = []
|
||||
return
|
||||
parts = [p for p in (h1, h2, h3) if p]
|
||||
breadcrumb = " > ".join(parts) if parts else (source or "(unnamed)")
|
||||
sections.append((breadcrumb, body, start_line))
|
||||
buffered = []
|
||||
|
||||
for lineno, line, in_code in _iter_nonfenced_lines(text):
|
||||
if in_code:
|
||||
buffered.append(line)
|
||||
continue
|
||||
m = _HEADER_RE.match(line.strip())
|
||||
if m is None:
|
||||
buffered.append(line)
|
||||
continue
|
||||
hashes, title = m.group(1), m.group(2).strip()
|
||||
level = len(hashes)
|
||||
if level == 1:
|
||||
# Document title — flush whatever we had, then set h1
|
||||
_flush(section_start_line)
|
||||
h1 = title
|
||||
h2 = None
|
||||
h3 = None
|
||||
section_start_line = lineno
|
||||
elif level == 2:
|
||||
_flush(section_start_line)
|
||||
h2 = title
|
||||
h3 = None
|
||||
section_start_line = lineno
|
||||
elif level == 3:
|
||||
_flush(section_start_line)
|
||||
h3 = title
|
||||
section_start_line = lineno
|
||||
else:
|
||||
# h4+ stays inline in the body
|
||||
buffered.append(line)
|
||||
_flush(section_start_line)
|
||||
|
||||
if not sections:
|
||||
# Doc had no ## / ### splits at all; fall back to treating the
|
||||
# whole thing as one section.
|
||||
parts = [p for p in (h1,) if p]
|
||||
breadcrumb = " > ".join(parts) if parts else (source or "(unnamed)")
|
||||
sections = [(breadcrumb, text.strip(), 0)]
|
||||
|
||||
# Pass 2: for each section, split if it's too large (token or char).
|
||||
chunks: List[MdChunk] = []
|
||||
|
||||
def _over_limit(tok_count: int, char_count: int) -> bool:
|
||||
return tok_count > max_section_tokens or char_count > max_section_chars
|
||||
|
||||
for breadcrumb, body, start_line in sections:
|
||||
body_tokens = body.split()
|
||||
if not _over_limit(len(body_tokens), len(body)):
|
||||
chunks.append(
|
||||
MdChunk(
|
||||
content=f"{breadcrumb}\n\n{body}",
|
||||
source=source,
|
||||
breadcrumb=breadcrumb,
|
||||
start_line=start_line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Oversized — slide over paragraphs, with a token-level fallback
|
||||
# for single paragraphs that are themselves larger than the window.
|
||||
paragraphs = [p for p in body.split("\n\n") if p.strip()]
|
||||
window_paragraphs: List[str] = []
|
||||
window_tokens = 0
|
||||
window_chars = 0
|
||||
|
||||
def _emit_window():
|
||||
nonlocal window_paragraphs, window_tokens, window_chars
|
||||
if not window_paragraphs:
|
||||
return
|
||||
chunk_body = "\n\n".join(window_paragraphs).strip()
|
||||
chunks.append(
|
||||
MdChunk(
|
||||
content=f"{breadcrumb}\n\n{chunk_body}",
|
||||
source=source,
|
||||
breadcrumb=breadcrumb,
|
||||
start_line=start_line,
|
||||
)
|
||||
)
|
||||
# Carry overlap tail into the next window
|
||||
tail = " ".join(chunk_body.split()[-paragraph_overlap_tokens:]) \
|
||||
if paragraph_overlap_tokens > 0 else ""
|
||||
window_paragraphs = [tail] if tail else []
|
||||
window_tokens = len(tail.split())
|
||||
window_chars = len(tail)
|
||||
|
||||
for para in paragraphs:
|
||||
p_tokens = para.split()
|
||||
|
||||
# Single paragraph too big for the window: flush what we
|
||||
# have, then slide a fixed token window over it.
|
||||
if _over_limit(len(p_tokens), len(para)):
|
||||
_emit_window()
|
||||
# Use whichever cap is tighter for this paragraph —
|
||||
# if it's char-bound, slide by chars; else by tokens.
|
||||
char_bound = (
|
||||
len(para) > max_section_chars
|
||||
and len(p_tokens) <= max_section_tokens
|
||||
)
|
||||
if char_bound:
|
||||
step_chars = max(
|
||||
1, max_section_chars - (paragraph_overlap_tokens * 8),
|
||||
)
|
||||
for i in range(0, len(para), step_chars):
|
||||
piece = para[i : i + max_section_chars]
|
||||
chunks.append(
|
||||
MdChunk(
|
||||
content=f"{breadcrumb}\n\n{piece}",
|
||||
source=source,
|
||||
breadcrumb=breadcrumb,
|
||||
start_line=start_line,
|
||||
)
|
||||
)
|
||||
if i + max_section_chars >= len(para):
|
||||
break
|
||||
else:
|
||||
step = max(1, max_section_tokens - paragraph_overlap_tokens)
|
||||
for i in range(0, len(p_tokens), step):
|
||||
window_content = " ".join(
|
||||
p_tokens[i : i + max_section_tokens]
|
||||
)
|
||||
# Safety: truncate if still over char cap
|
||||
if len(window_content) > max_section_chars:
|
||||
window_content = window_content[:max_section_chars]
|
||||
chunks.append(
|
||||
MdChunk(
|
||||
content=f"{breadcrumb}\n\n{window_content}",
|
||||
source=source,
|
||||
breadcrumb=breadcrumb,
|
||||
start_line=start_line,
|
||||
)
|
||||
)
|
||||
if i + max_section_tokens >= len(p_tokens):
|
||||
break
|
||||
window_paragraphs = []
|
||||
window_tokens = 0
|
||||
window_chars = 0
|
||||
continue
|
||||
|
||||
# Would adding this paragraph push us over either limit?
|
||||
sep = 2 if window_paragraphs else 0 # for the "\n\n" between paras
|
||||
if (
|
||||
_over_limit(
|
||||
window_tokens + len(p_tokens),
|
||||
window_chars + len(para) + sep,
|
||||
)
|
||||
and window_paragraphs
|
||||
):
|
||||
_emit_window()
|
||||
window_paragraphs.append(para)
|
||||
window_tokens += len(p_tokens)
|
||||
window_chars += len(para) + (2 if len(window_paragraphs) > 1 else 0)
|
||||
|
||||
_emit_window()
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-file deduplication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_NORM_WS_RE = re.compile(r"\s+")
|
||||
_NORM_NONALPHA_RE = re.compile(r"[^a-z0-9\s]+")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DuplicateGroup:
|
||||
"""A cluster of chunks judged to be near-duplicates of each other."""
|
||||
|
||||
kept_index: int # surviving chunk's index in the input list
|
||||
kept_source: str
|
||||
dropped_indices: List[int] = field(default_factory=list)
|
||||
dropped_sources: List[str] = field(default_factory=list)
|
||||
distinct_files: int = 0 # # of unique source files in the group
|
||||
sample_text: str = "" # ~120-char preview of the duplicated content
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DedupeReport:
|
||||
"""Audit trail for a deduplication pass."""
|
||||
|
||||
input_count: int = 0
|
||||
output_count: int = 0
|
||||
groups: List[DuplicateGroup] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def removed_count(self) -> int:
|
||||
return self.input_count - self.output_count
|
||||
|
||||
@property
|
||||
def removed_fraction(self) -> float:
|
||||
return self.removed_count / self.input_count if self.input_count else 0.0
|
||||
|
||||
|
||||
def _strip_breadcrumb(content: str) -> str:
|
||||
"""Drop the breadcrumb prefix produced by chunk_markdown.
|
||||
|
||||
The breadcrumb varies between files even for boilerplate body text
|
||||
(Downloads vs Installation, etc.), which would suppress similarity
|
||||
if included in the n-gram set. Compare body-only.
|
||||
"""
|
||||
parts = content.split("\n\n", 1)
|
||||
return parts[1] if len(parts) == 2 else content
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""Lowercase, drop non-alphanumeric, collapse whitespace."""
|
||||
text = text.lower()
|
||||
text = _NORM_NONALPHA_RE.sub(" ", text)
|
||||
text = _NORM_WS_RE.sub(" ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _ngrams(text: str, n: int = 5) -> set:
|
||||
"""Word-level n-gram set."""
|
||||
tokens = text.split()
|
||||
if len(tokens) < n:
|
||||
# Short chunks: use the whole token tuple as a single n-gram so
|
||||
# very-short identical chunks still cluster.
|
||||
return {tuple(tokens)} if tokens else set()
|
||||
return {tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)}
|
||||
|
||||
|
||||
def _jaccard(a: set, b: set) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
inter = len(a & b)
|
||||
if inter == 0:
|
||||
return 0.0
|
||||
return inter / len(a | b)
|
||||
|
||||
|
||||
class _UnionFind:
|
||||
"""Tiny union-find for clustering near-duplicate chunks."""
|
||||
|
||||
def __init__(self, n: int) -> None:
|
||||
self.parent = list(range(n))
|
||||
|
||||
def find(self, x: int) -> int:
|
||||
while self.parent[x] != x:
|
||||
self.parent[x] = self.parent[self.parent[x]]
|
||||
x = self.parent[x]
|
||||
return x
|
||||
|
||||
def union(self, a: int, b: int) -> None:
|
||||
ra, rb = self.find(a), self.find(b)
|
||||
if ra != rb:
|
||||
self.parent[ra] = rb
|
||||
|
||||
|
||||
def _path_specificity(source: str) -> Tuple[int, int, str]:
|
||||
"""Sort key — bigger is more specific.
|
||||
|
||||
Tiebreakers:
|
||||
1. Path depth (slashes) — deeper = more specific.
|
||||
2. Length of basename — proxy for descriptiveness.
|
||||
3. Lexicographic source path — deterministic last-resort.
|
||||
"""
|
||||
if not source:
|
||||
return (-1, 0, "")
|
||||
depth = source.count("/")
|
||||
basename = source.rsplit("/", 1)[-1]
|
||||
return (depth, len(basename), source)
|
||||
|
||||
|
||||
def dedupe_chunks(
|
||||
chunks: List[MdChunk],
|
||||
*,
|
||||
ngram_n: int = 5,
|
||||
similarity_threshold: float = 0.7,
|
||||
min_files_for_dup: int = 3,
|
||||
) -> Tuple[List[MdChunk], DedupeReport]:
|
||||
"""Drop near-duplicate chunks that recur across many source files.
|
||||
|
||||
A chunk cluster is considered a duplicate (and collapsed to one
|
||||
canonical entry) when:
|
||||
|
||||
1. Pairwise word-level n-gram **Jaccard >= ``similarity_threshold``**
|
||||
on the body text (breadcrumb stripped). N-grams are computed
|
||||
after lowercasing and stripping non-alphanumeric punctuation,
|
||||
so superficial differences (capitalization, typography) don't
|
||||
hide duplication.
|
||||
2. The cluster spans **>= ``min_files_for_dup`` distinct source
|
||||
files**. Two-file repeats are kept on the assumption they may
|
||||
be legitimately doc-specific; >=3 is the bar for boilerplate.
|
||||
|
||||
For each qualifying cluster, the chunk from the most-specific
|
||||
source path wins (deepest dir, longest basename, lexicographic
|
||||
tiebreak); the rest are dropped.
|
||||
|
||||
Returns ``(surviving_chunks, report)``. The chunker, embedder and
|
||||
retrieval logic are NOT modified — this function is a pure pre-
|
||||
processing pass over the chunk list before embedding.
|
||||
"""
|
||||
n = len(chunks)
|
||||
if n == 0:
|
||||
return [], DedupeReport()
|
||||
|
||||
# 1) Compute n-gram set for each chunk (body only)
|
||||
chunk_ngrams: List[set] = []
|
||||
for c in chunks:
|
||||
body = _strip_breadcrumb(c.content)
|
||||
chunk_ngrams.append(_ngrams(_normalize(body), n=ngram_n))
|
||||
|
||||
# 2) Inverted index: ngram -> [chunk indices that contain it]
|
||||
# Lets us skip pairs that share zero n-grams without computing Jaccard.
|
||||
inverted: Dict[tuple, List[int]] = defaultdict(list)
|
||||
for i, ngs in enumerate(chunk_ngrams):
|
||||
for ng in ngs:
|
||||
inverted[ng].append(i)
|
||||
|
||||
# 3) Build candidate-pair set: any pair sharing at least one n-gram.
|
||||
# For each n-gram, at most ``cap`` chunks contribute to candidate
|
||||
# pairs to avoid quadratic blowup on hyper-common n-grams (e.g.
|
||||
# ``("the", "the", ...)``-style noise — unlikely but defensive).
|
||||
cap = 200
|
||||
candidate_pairs: set = set()
|
||||
for chunk_ids in inverted.values():
|
||||
if len(chunk_ids) < 2:
|
||||
continue
|
||||
if len(chunk_ids) > cap:
|
||||
chunk_ids = chunk_ids[:cap]
|
||||
for i_idx in range(len(chunk_ids)):
|
||||
for j_idx in range(i_idx + 1, len(chunk_ids)):
|
||||
a, b = chunk_ids[i_idx], chunk_ids[j_idx]
|
||||
if a > b:
|
||||
a, b = b, a
|
||||
candidate_pairs.add((a, b))
|
||||
|
||||
# 4) Cluster via union-find on pairs that exceed the threshold.
|
||||
uf = _UnionFind(n)
|
||||
for a, b in candidate_pairs:
|
||||
if _jaccard(chunk_ngrams[a], chunk_ngrams[b]) >= similarity_threshold:
|
||||
uf.union(a, b)
|
||||
|
||||
# 5) Group by cluster root
|
||||
cluster_to_members: Dict[int, List[int]] = defaultdict(list)
|
||||
for i in range(n):
|
||||
cluster_to_members[uf.find(i)].append(i)
|
||||
|
||||
# 6) For each cluster, decide: dedupe or keep all?
|
||||
keep_mask = [True] * n
|
||||
report = DedupeReport(input_count=n)
|
||||
for members in cluster_to_members.values():
|
||||
if len(members) < 2:
|
||||
continue
|
||||
distinct_files = {chunks[i].source for i in members}
|
||||
if len(distinct_files) < min_files_for_dup:
|
||||
continue # not boilerplate enough — keep all
|
||||
|
||||
# Pick canonical: most-specific source path
|
||||
sorted_members = sorted(
|
||||
members,
|
||||
key=lambda i: _path_specificity(chunks[i].source),
|
||||
reverse=True,
|
||||
)
|
||||
kept_idx = sorted_members[0]
|
||||
dropped_idxs = sorted_members[1:]
|
||||
for d in dropped_idxs:
|
||||
keep_mask[d] = False
|
||||
|
||||
sample = _strip_breadcrumb(chunks[kept_idx].content).strip().replace("\n", " ")
|
||||
report.groups.append(
|
||||
DuplicateGroup(
|
||||
kept_index=kept_idx,
|
||||
kept_source=chunks[kept_idx].source,
|
||||
dropped_indices=dropped_idxs,
|
||||
dropped_sources=[chunks[i].source for i in dropped_idxs],
|
||||
distinct_files=len(distinct_files),
|
||||
sample_text=sample[:120] + ("..." if len(sample) > 120 else ""),
|
||||
)
|
||||
)
|
||||
|
||||
survivors = [c for i, c in enumerate(chunks) if keep_mask[i]]
|
||||
report.output_count = len(survivors)
|
||||
return survivors, report
|
||||
|
||||
|
||||
def log_dedupe_report(report: DedupeReport, *, level: int = logging.INFO) -> None:
|
||||
"""Emit a human-readable summary of a dedupe pass at ``level``."""
|
||||
pct = report.removed_fraction * 100
|
||||
logger.log(
|
||||
level,
|
||||
"dedupe: %d -> %d chunks (%d removed, %.1f%%) across %d clusters",
|
||||
report.input_count,
|
||||
report.output_count,
|
||||
report.removed_count,
|
||||
pct,
|
||||
len(report.groups),
|
||||
)
|
||||
for grp in report.groups:
|
||||
logger.log(
|
||||
level,
|
||||
" kept %s dropped %d (across %d files): %s | %r",
|
||||
grp.kept_source,
|
||||
len(grp.dropped_indices),
|
||||
grp.distinct_files,
|
||||
", ".join(sorted(set(grp.dropped_sources))),
|
||||
grp.sample_text,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DenseMemory backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@MemoryRegistry.register("dense")
|
||||
class DenseMemory(MemoryBackend):
|
||||
"""In-memory dense retrieval via cosine similarity.
|
||||
|
||||
The embedder is lazy: it is created on first :meth:`store` or
|
||||
:meth:`retrieve` call, so instantiating :class:`DenseMemory` does
|
||||
not require Ollama to be running.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
embedder:
|
||||
An :class:`Embedder` instance. If ``None``, defaults to
|
||||
:class:`OllamaEmbedder` with ``nomic-embed-text``.
|
||||
"""
|
||||
|
||||
backend_id = "dense"
|
||||
|
||||
def __init__(self, embedder: Optional[Embedder] = None) -> None:
|
||||
self._embedder: Optional[Embedder] = embedder
|
||||
# Shape (n_docs, dim), L2-normalized row-wise. None until first store.
|
||||
self._matrix = None
|
||||
self._contents: List[str] = []
|
||||
self._sources: List[str] = []
|
||||
self._metadatas: List[Dict[str, Any]] = []
|
||||
self._doc_ids: List[str] = []
|
||||
# id -> index; lets us delete in O(1) for lookups
|
||||
self._id_to_index: Dict[str, int] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# -- embedder lifecycle ------------------------------------------------
|
||||
|
||||
def _get_embedder(self) -> Embedder:
|
||||
if self._embedder is None:
|
||||
self._embedder = OllamaEmbedder()
|
||||
return self._embedder
|
||||
|
||||
# -- MemoryBackend ABC -------------------------------------------------
|
||||
|
||||
def store(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
source: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Embed and store one document. Returns its id."""
|
||||
return self.store_many(
|
||||
[content], sources=[source], metadatas=[metadata or {}],
|
||||
)[0]
|
||||
|
||||
def store_many(
|
||||
self,
|
||||
contents: List[str],
|
||||
*,
|
||||
sources: Optional[List[str]] = None,
|
||||
metadatas: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[str]:
|
||||
"""Embed a batch of documents in one go. Much faster than per-doc.
|
||||
|
||||
Accepts parallel lists; missing sources/metadatas default to empty.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if not contents:
|
||||
return []
|
||||
sources = sources if sources is not None else [""] * len(contents)
|
||||
metadatas = metadatas if metadatas is not None else [{} for _ in contents]
|
||||
assert len(sources) == len(contents) and len(metadatas) == len(contents)
|
||||
|
||||
emb = self._get_embedder()
|
||||
vectors = emb.embed(contents) # already normalized
|
||||
new_ids = [uuid.uuid4().hex for _ in contents]
|
||||
|
||||
with self._lock:
|
||||
if self._matrix is None:
|
||||
self._matrix = vectors
|
||||
else:
|
||||
self._matrix = np.concatenate([self._matrix, vectors], axis=0)
|
||||
for i, (c, s, m, doc_id) in enumerate(
|
||||
zip(contents, sources, metadatas, new_ids),
|
||||
):
|
||||
self._contents.append(c)
|
||||
self._sources.append(s)
|
||||
self._metadatas.append(dict(m))
|
||||
self._doc_ids.append(doc_id)
|
||||
self._id_to_index[doc_id] = len(self._contents) - 1
|
||||
return new_ids
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
top_k: int = 5,
|
||||
**kwargs: Any,
|
||||
) -> List[RetrievalResult]:
|
||||
"""Return top-k documents by cosine similarity.
|
||||
|
||||
Scores are in ``[-1, 1]`` for normalized vectors; for
|
||||
nomic-embed-text in practice scores on reasonable queries
|
||||
fall in ``[0.3, 0.8]``. Empty index or query → empty list.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
|
||||
with self._lock:
|
||||
matrix_snapshot = self._matrix
|
||||
contents = list(self._contents)
|
||||
sources = list(self._sources)
|
||||
metadatas = [dict(m) for m in self._metadatas]
|
||||
doc_ids = list(self._doc_ids)
|
||||
|
||||
if matrix_snapshot is None or matrix_snapshot.shape[0] == 0:
|
||||
return []
|
||||
|
||||
emb = self._get_embedder()
|
||||
q_vec = emb.embed([query]) # shape (1, dim), normalized
|
||||
# Single matrix-vector mult
|
||||
scores = matrix_snapshot @ q_vec[0] # shape (n,)
|
||||
|
||||
# Top-k via argpartition then sort
|
||||
n = scores.shape[0]
|
||||
k = min(top_k, n)
|
||||
if k <= 0:
|
||||
return []
|
||||
if k < n:
|
||||
top_idx = np.argpartition(-scores, k - 1)[:k]
|
||||
else:
|
||||
top_idx = np.arange(n)
|
||||
# Sort the top-k chunk descending by score
|
||||
top_idx = top_idx[np.argsort(-scores[top_idx])]
|
||||
|
||||
results: List[RetrievalResult] = []
|
||||
for i in top_idx:
|
||||
i = int(i)
|
||||
results.append(
|
||||
RetrievalResult(
|
||||
content=contents[i],
|
||||
score=float(scores[i]),
|
||||
source=sources[i],
|
||||
metadata={**metadatas[i], "doc_id": doc_ids[i]},
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
def delete(self, doc_id: str) -> bool:
|
||||
"""Remove a document by id. Returns True if it existed."""
|
||||
import numpy as np
|
||||
|
||||
with self._lock:
|
||||
idx = self._id_to_index.pop(doc_id, None)
|
||||
if idx is None:
|
||||
return False
|
||||
# Remove row from matrix
|
||||
self._matrix = np.delete(self._matrix, idx, axis=0)
|
||||
self._contents.pop(idx)
|
||||
self._sources.pop(idx)
|
||||
self._metadatas.pop(idx)
|
||||
self._doc_ids.pop(idx)
|
||||
# Rebuild id -> index for entries after the removed one
|
||||
for did, i in list(self._id_to_index.items()):
|
||||
if i > idx:
|
||||
self._id_to_index[did] = i - 1
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Drop all stored documents."""
|
||||
with self._lock:
|
||||
self._matrix = None
|
||||
self._contents.clear()
|
||||
self._sources.clear()
|
||||
self._metadatas.clear()
|
||||
self._doc_ids.clear()
|
||||
self._id_to_index.clear()
|
||||
|
||||
def count(self) -> int:
|
||||
"""Number of stored documents."""
|
||||
with self._lock:
|
||||
return len(self._contents)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DenseMemory",
|
||||
"DedupeReport",
|
||||
"DuplicateGroup",
|
||||
"MdChunk",
|
||||
"chunk_markdown",
|
||||
"dedupe_chunks",
|
||||
"log_dedupe_report",
|
||||
]
|
||||
@@ -1,13 +1,10 @@
|
||||
"""Embeddings abstraction for dense retrieval backends.
|
||||
|
||||
Provides an ABC and a default ``SentenceTransformerEmbedder`` that wraps
|
||||
the ``sentence-transformers`` library.
|
||||
"""
|
||||
"""Embeddings abstraction for dense retrieval backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from typing import Any, List, Optional
|
||||
|
||||
|
||||
class Embedder(ABC):
|
||||
@@ -59,4 +56,118 @@ class SentenceTransformerEmbedder(Embedder):
|
||||
return self._dim
|
||||
|
||||
|
||||
__all__ = ["Embedder", "SentenceTransformerEmbedder"]
|
||||
class OllamaEmbedder(Embedder):
|
||||
"""Embedder backed by an Ollama server's ``/api/embed`` endpoint.
|
||||
|
||||
Sends batches in parallel (up to ``max_parallel``) since Ollama
|
||||
serializes items within a single HTTP request but happily serves
|
||||
multiple concurrent connections.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
model:
|
||||
Ollama model tag; defaults to ``nomic-embed-text`` (768-dim).
|
||||
base_url:
|
||||
Ollama server base URL. Defaults to ``http://localhost:11434``.
|
||||
batch_size:
|
||||
Items per HTTP request. Tuned for Ollama — larger batches help
|
||||
throughput but increase memory on the server.
|
||||
max_parallel:
|
||||
How many concurrent HTTP requests to issue. Ollama CPU/GPU
|
||||
saturation is typically hit around 8.
|
||||
timeout_s:
|
||||
Per-request timeout.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "nomic-embed-text",
|
||||
base_url: str = "http://localhost:11434",
|
||||
*,
|
||||
batch_size: int = 16,
|
||||
max_parallel: int = 8,
|
||||
timeout_s: float = 120.0,
|
||||
) -> None:
|
||||
import httpx # local import to keep module light if unused
|
||||
|
||||
self._model = model
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._batch_size = max(1, batch_size)
|
||||
self._max_parallel = max(1, max_parallel)
|
||||
self._timeout_s = timeout_s
|
||||
self._httpx = httpx
|
||||
self._dim_cached: Optional[int] = None
|
||||
|
||||
def _embed_batch(self, texts: List[str]) -> List[List[float]]:
|
||||
"""Issue one HTTP request for ``texts`` and return raw vectors."""
|
||||
resp = self._httpx.post(
|
||||
f"{self._base_url}/api/embed",
|
||||
json={"model": self._model, "input": texts},
|
||||
timeout=self._timeout_s,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
embeddings = data.get("embeddings")
|
||||
if not embeddings:
|
||||
raise RuntimeError(
|
||||
f"Ollama returned no embeddings for model {self._model!r}. "
|
||||
f"Response keys: {list(data.keys())}"
|
||||
)
|
||||
return embeddings
|
||||
|
||||
def embed(self, texts: list[str]) -> Any:
|
||||
"""Return a numpy array of shape ``(len(texts), dim)``.
|
||||
|
||||
Float32, L2-normalized per row so callers can use dot-product
|
||||
as cosine similarity. Empty input → shape ``(0, dim)``.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if not texts:
|
||||
return np.zeros((0, self.dim()), dtype=np.float32)
|
||||
|
||||
# Partition into batches
|
||||
batches = [
|
||||
texts[i : i + self._batch_size]
|
||||
for i in range(0, len(texts), self._batch_size)
|
||||
]
|
||||
|
||||
# Fan out batches concurrently
|
||||
results: List[Optional[List[List[float]]]] = [None] * len(batches)
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=min(self._max_parallel, len(batches)),
|
||||
) as pool:
|
||||
future_to_idx = {
|
||||
pool.submit(self._embed_batch, batch): i
|
||||
for i, batch in enumerate(batches)
|
||||
}
|
||||
for future in concurrent.futures.as_completed(future_to_idx):
|
||||
idx = future_to_idx[future]
|
||||
results[idx] = future.result()
|
||||
|
||||
# Stitch back in order and stack
|
||||
flat: List[List[float]] = []
|
||||
for batch_result in results:
|
||||
assert batch_result is not None # all futures returned
|
||||
flat.extend(batch_result)
|
||||
|
||||
arr = np.asarray(flat, dtype=np.float32)
|
||||
# L2-normalize rows; guard against zero vectors
|
||||
norms = np.linalg.norm(arr, axis=1, keepdims=True)
|
||||
norms = np.where(norms == 0, 1.0, norms)
|
||||
arr = arr / norms
|
||||
|
||||
if self._dim_cached is None:
|
||||
self._dim_cached = int(arr.shape[1])
|
||||
return arr
|
||||
|
||||
def dim(self) -> int:
|
||||
"""Return the embedding dimensionality (probes the server on first call)."""
|
||||
if self._dim_cached is None:
|
||||
# Probe with a single trivial input
|
||||
vecs = self._embed_batch(["probe"])
|
||||
self._dim_cached = len(vecs[0])
|
||||
return self._dim_cached
|
||||
|
||||
|
||||
__all__ = ["Embedder", "OllamaEmbedder", "SentenceTransformerEmbedder"]
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Tests for the TwitterChannel adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.channels._stubs import ChannelStatus
|
||||
from openjarvis.channels.twitter import TwitterChannel
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
from tests.channels.channel_test_helpers import make_common_channel_tests
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _register_twitter():
|
||||
"""Re-register after any registry clear."""
|
||||
if not ChannelRegistry.contains("twitter"):
|
||||
ChannelRegistry.register_value("twitter", TwitterChannel)
|
||||
|
||||
|
||||
TestCommonChannel = make_common_channel_tests(TwitterChannel, "twitter")
|
||||
# Twitter overrides list_channels() to return ["timeline", "dm"],
|
||||
# so remove the generic assertion and keep the channel-specific test below.
|
||||
del TestCommonChannel.test_list_channels
|
||||
|
||||
|
||||
def test_twitter_no_credentials_status():
|
||||
"""Without credentials, connect() sets status to ERROR."""
|
||||
ch = TwitterChannel()
|
||||
ch.connect()
|
||||
assert ch.status() == ChannelStatus.ERROR
|
||||
|
||||
|
||||
def test_twitter_send_no_credentials_returns_false():
|
||||
"""send() returns False when client is not connected."""
|
||||
ch = TwitterChannel()
|
||||
result = ch.send("timeline", "Hello Twitter!")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_twitter_send_tweet():
|
||||
"""send() with a non-numeric channel calls create_tweet."""
|
||||
ch = TwitterChannel(bearer_token="test-bearer")
|
||||
mock_client = MagicMock()
|
||||
ch._client = mock_client
|
||||
ch._status = ChannelStatus.CONNECTED
|
||||
|
||||
result = ch.send("timeline", "Hello from Jarvis!")
|
||||
assert result is True
|
||||
mock_client.create_tweet.assert_called_once_with(text="Hello from Jarvis!")
|
||||
|
||||
|
||||
def test_twitter_send_dm_when_channel_is_numeric():
|
||||
"""send() with a numeric channel calls create_direct_message."""
|
||||
ch = TwitterChannel(bearer_token="test-bearer")
|
||||
mock_client = MagicMock()
|
||||
ch._client = mock_client
|
||||
ch._status = ChannelStatus.CONNECTED
|
||||
|
||||
result = ch.send("12345678", "Hello via DM!")
|
||||
assert result is True
|
||||
mock_client.create_direct_message.assert_called_once_with(
|
||||
participant_id=12345678,
|
||||
text="Hello via DM!",
|
||||
)
|
||||
|
||||
|
||||
def test_twitter_send_reply():
|
||||
"""send() with conversation_id sets in_reply_to_tweet_id."""
|
||||
ch = TwitterChannel(bearer_token="test-bearer")
|
||||
mock_client = MagicMock()
|
||||
ch._client = mock_client
|
||||
ch._status = ChannelStatus.CONNECTED
|
||||
|
||||
result = ch.send(
|
||||
"timeline",
|
||||
"This is a reply",
|
||||
conversation_id="999888777",
|
||||
)
|
||||
assert result is True
|
||||
mock_client.create_tweet.assert_called_once_with(
|
||||
text="This is a reply",
|
||||
in_reply_to_tweet_id=999888777,
|
||||
)
|
||||
|
||||
|
||||
def test_twitter_list_channels():
|
||||
"""list_channels() returns the expected identifiers."""
|
||||
ch = TwitterChannel()
|
||||
assert ch.list_channels() == ["timeline", "dm"]
|
||||
|
||||
|
||||
def test_twitter_event_bus_integration():
|
||||
"""Successful send publishes CHANNEL_MESSAGE_SENT on the event bus."""
|
||||
bus = EventBus(record_history=True)
|
||||
ch = TwitterChannel(bearer_token="test-bearer", bus=bus)
|
||||
mock_client = MagicMock()
|
||||
ch._client = mock_client
|
||||
ch._status = ChannelStatus.CONNECTED
|
||||
|
||||
ch.send("timeline", "Event test!")
|
||||
|
||||
event_types = [e.event_type for e in bus.history]
|
||||
assert EventType.CHANNEL_MESSAGE_SENT in event_types
|
||||
|
||||
sent_event = next(
|
||||
e for e in bus.history if e.event_type == EventType.CHANNEL_MESSAGE_SENT
|
||||
)
|
||||
assert sent_event.data["content"] == "Event test!"
|
||||
assert sent_event.data["channel"] == "timeline"
|
||||
|
||||
|
||||
@pytest.mark.live_channel
|
||||
def test_twitter_connect_live():
|
||||
"""Live test: connect with real credentials from env vars.
|
||||
|
||||
Skipped unless all required env vars are set.
|
||||
"""
|
||||
required_vars = [
|
||||
"TWITTER_BEARER_TOKEN",
|
||||
"TWITTER_API_KEY",
|
||||
"TWITTER_API_SECRET",
|
||||
"TWITTER_ACCESS_TOKEN",
|
||||
"TWITTER_ACCESS_SECRET",
|
||||
]
|
||||
for var in required_vars:
|
||||
if not os.environ.get(var):
|
||||
pytest.skip(f"Missing env var {var}")
|
||||
|
||||
ch = TwitterChannel()
|
||||
ch.connect()
|
||||
assert ch.status() == ChannelStatus.CONNECTED
|
||||
ch.disconnect()
|
||||
assert ch.status() == ChannelStatus.DISCONNECTED
|
||||
@@ -0,0 +1,738 @@
|
||||
"""End-to-end tests for the Twitter bot mention handler.
|
||||
|
||||
Tests cover:
|
||||
- Tweet classification (_classify_mention)
|
||||
- Prompt building for each mention type
|
||||
- Mention polling → handler dispatch
|
||||
- Full reactive flow: mention → classify → prompt → agent → tool call → reply
|
||||
- Environment variable expansion in http_request headers (GitHub issue creation)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import the bot module helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
import sys
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.channels._stubs import ChannelMessage
|
||||
from openjarvis.channels.twitter_channel import TwitterChannel
|
||||
from openjarvis.tools.http_request import HttpRequestTool
|
||||
|
||||
# Add examples dir to path so we can import the bot module
|
||||
_EXAMPLES_DIR = os.path.join(
|
||||
os.path.dirname(__file__), os.pardir, os.pardir, "examples", "twitter_bot",
|
||||
)
|
||||
sys.path.insert(0, os.path.abspath(_EXAMPLES_DIR))
|
||||
twitter_bot = importlib.import_module("twitter_bot")
|
||||
sys.path.pop(0)
|
||||
|
||||
_classify_mention = twitter_bot._classify_mention
|
||||
_build_question_prompt = twitter_bot._build_question_prompt
|
||||
_build_question_grounded_prompt = twitter_bot._build_question_grounded_prompt
|
||||
_build_question_deferral_prompt = twitter_bot._build_question_deferral_prompt
|
||||
_build_bug_prompt = twitter_bot._build_bug_prompt
|
||||
_build_feature_prompt = twitter_bot._build_feature_prompt
|
||||
_build_praise_prompt = twitter_bot._build_praise_prompt
|
||||
DEMO_TWEETS = twitter_bot.DEMO_TWEETS
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. Classification tests
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestClassifyMention:
|
||||
"""Test the keyword-based mention classifier."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("@OpenJarvisAI bug: the memory_search tool crashes", "BUG_REPORT"),
|
||||
("@OpenJarvisAI crash when I run jarvis ask", "BUG_REPORT"),
|
||||
("@OpenJarvisAI error on startup with ollama", "BUG_REPORT"),
|
||||
("@OpenJarvisAI the CLI fails after update", "BUG_REPORT"),
|
||||
("@OpenJarvisAI broken link in the docs", "BUG_REPORT"),
|
||||
("@OpenJarvisAI segfault with large file", "BUG_REPORT"),
|
||||
],
|
||||
)
|
||||
def test_bug_report(self, text, expected):
|
||||
assert _classify_mention(text) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("@OpenJarvisAI feature request: add a scheduler UI", "FEATURE_REQUEST"),
|
||||
("@OpenJarvisAI would love a web dashboard", "FEATURE_REQUEST"),
|
||||
(
|
||||
"@OpenJarvisAI it would be great to have notifications",
|
||||
"FEATURE_REQUEST",
|
||||
),
|
||||
("@OpenJarvisAI I wish there was a mobile app", "FEATURE_REQUEST"),
|
||||
("@OpenJarvisAI please add dark mode", "FEATURE_REQUEST"),
|
||||
("@OpenJarvisAI can you add voice input?", "FEATURE_REQUEST"),
|
||||
("@OpenJarvisAI any plans for a VS Code extension?", "FEATURE_REQUEST"),
|
||||
],
|
||||
)
|
||||
def test_feature_request(self, text, expected):
|
||||
assert _classify_mention(text) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("@OpenJarvisAI just discovered this, love it!", "PRAISE"),
|
||||
("@OpenJarvisAI this is amazing work", "PRAISE"),
|
||||
("@OpenJarvisAI awesome project, great work!", "PRAISE"),
|
||||
("@OpenJarvisAI I'm impressed by the speed", "PRAISE"),
|
||||
("@OpenJarvisAI switched from langchain, incredible", "PRAISE"),
|
||||
],
|
||||
)
|
||||
def test_praise(self, text, expected):
|
||||
assert _classify_mention(text) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("@OpenJarvisAI BUY CRYPTO NOW", "SPAM"),
|
||||
("@OpenJarvisAI free download link in bio", "SPAM"),
|
||||
("@OpenJarvisAI guaranteed income 10x returns", "SPAM"),
|
||||
],
|
||||
)
|
||||
def test_spam(self, text, expected):
|
||||
assert _classify_mention(text) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("@OpenJarvisAI how do I add a new channel?", "QUESTION"),
|
||||
("@OpenJarvisAI what models do you support?", "QUESTION"),
|
||||
("@OpenJarvisAI does this work on Windows?", "QUESTION"),
|
||||
("@OpenJarvisAI tell me about the architecture", "QUESTION"),
|
||||
],
|
||||
)
|
||||
def test_question(self, text, expected):
|
||||
assert _classify_mention(text) == expected
|
||||
|
||||
def test_demo_tweets_cover_all_types(self):
|
||||
"""The built-in DEMO_TWEETS should cover all five categories."""
|
||||
types = {_classify_mention(t["text"]) for t in DEMO_TWEETS}
|
||||
assert types == {"QUESTION", "BUG_REPORT", "FEATURE_REQUEST", "PRAISE", "SPAM"}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. Prompt builder tests
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestPromptBuilders:
|
||||
"""Verify prompt builders produce well-formed prompts with the right info."""
|
||||
|
||||
def test_question_deferral_prompt_has_channel_send(self):
|
||||
"""Deferral prompt (used when retrieval is empty/weak)."""
|
||||
prompt = _build_question_deferral_prompt("alice", "123", "how do I install?")
|
||||
assert "channel_send" in prompt
|
||||
assert "alice" in prompt
|
||||
assert "123" in prompt
|
||||
assert "how do I install?" in prompt
|
||||
# Deferral prompt explicitly tells the model NOT to guess
|
||||
assert "do not guess" in prompt.lower() or "do not make up" in prompt.lower()
|
||||
|
||||
def test_question_grounded_prompt_embeds_context(self):
|
||||
"""Grounded prompt (used when retrieval score >= threshold)."""
|
||||
context = "[1] hardware.md — Running Without a GPU\nUse llama.cpp for CPU."
|
||||
prompt = _build_question_grounded_prompt(
|
||||
"alice", "123", "can I run on cpu?", context, 0.71
|
||||
)
|
||||
assert "channel_send" in prompt
|
||||
assert context in prompt
|
||||
# Grounded prompt must instruct the model to answer ONLY from context
|
||||
lc = prompt.lower()
|
||||
assert (
|
||||
"only from facts in the context" in lc
|
||||
or "only from the context" in lc
|
||||
)
|
||||
|
||||
def test_bug_prompt_contains_github_url(self):
|
||||
prompt = _build_bug_prompt("bob", "456", "crash on startup")
|
||||
assert "api.github.com/repos/open-jarvis/OpenJarvis/issues" in prompt
|
||||
assert "http_request" in prompt
|
||||
assert "channel_send" in prompt
|
||||
assert "bob" in prompt
|
||||
assert "bug" in prompt
|
||||
assert "456" in prompt
|
||||
|
||||
def test_feature_prompt_contains_github_url(self):
|
||||
prompt = _build_feature_prompt("carol", "789", "add dark mode")
|
||||
assert "api.github.com/repos/open-jarvis/OpenJarvis/issues" in prompt
|
||||
assert "enhancement" in prompt
|
||||
assert "carol" in prompt
|
||||
assert "789" in prompt
|
||||
|
||||
def test_praise_prompt_has_channel_send(self):
|
||||
prompt = _build_praise_prompt("dave", "101", "love this project!")
|
||||
assert "channel_send" in prompt
|
||||
assert "dave" in prompt
|
||||
assert "101" in prompt
|
||||
|
||||
def test_all_prompts_include_voice_rules(self):
|
||||
"""Every prompt should include the voice rules (280 chars, lowercase, etc.)."""
|
||||
prompts = [
|
||||
_build_question_prompt("u", "1", "q"),
|
||||
_build_bug_prompt("u", "1", "b"),
|
||||
_build_feature_prompt("u", "1", "f"),
|
||||
_build_praise_prompt("u", "1", "p"),
|
||||
]
|
||||
for prompt in prompts:
|
||||
assert "<=280 characters" in prompt
|
||||
assert "lowercase prose" in prompt
|
||||
|
||||
def test_bug_prompt_includes_from_twitter_label(self):
|
||||
prompt = _build_bug_prompt("user", "1", "crash")
|
||||
assert "from-twitter" in prompt
|
||||
|
||||
def test_feature_prompt_includes_from_twitter_label(self):
|
||||
prompt = _build_feature_prompt("user", "1", "feature")
|
||||
assert "from-twitter" in prompt
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. Mention polling + handler dispatch
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestMentionPolling:
|
||||
"""Test that _poll_mentions fetches tweets and dispatches to handlers."""
|
||||
|
||||
def test_poll_dispatches_to_handler(self):
|
||||
"""A single poll cycle should dispatch tweets to registered handlers."""
|
||||
ch = TwitterChannel(
|
||||
bearer_token="test-bearer",
|
||||
bot_user_id="999",
|
||||
poll_interval=1,
|
||||
)
|
||||
|
||||
handler = MagicMock()
|
||||
ch.on_message(handler)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"data": [
|
||||
{
|
||||
"id": "111",
|
||||
"author_id": "alice",
|
||||
"text": "@OpenJarvisAI how do I install?",
|
||||
"conversation_id": "111",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with patch("httpx.get", return_value=mock_response):
|
||||
ch._stop_event = threading.Event()
|
||||
|
||||
def poll_once():
|
||||
import httpx as _httpx
|
||||
headers = {"Authorization": "Bearer test-bearer"}
|
||||
url = "https://api.twitter.com/2/users/999/mentions"
|
||||
params = {"tweet.fields": "author_id,conversation_id,created_at"}
|
||||
resp = _httpx.get(url, headers=headers, params=params, timeout=10.0)
|
||||
data = resp.json()
|
||||
for tweet in data.get("data", []):
|
||||
cm = ChannelMessage(
|
||||
channel="twitter",
|
||||
sender=tweet.get("author_id", ""),
|
||||
content=tweet.get("text", ""),
|
||||
message_id=tweet["id"],
|
||||
conversation_id=tweet.get("conversation_id", ""),
|
||||
)
|
||||
for h in ch._handlers:
|
||||
h(cm)
|
||||
|
||||
poll_once()
|
||||
|
||||
handler.assert_called_once()
|
||||
msg = handler.call_args[0][0]
|
||||
assert isinstance(msg, ChannelMessage)
|
||||
assert msg.sender == "alice"
|
||||
assert msg.content == "@OpenJarvisAI how do I install?"
|
||||
assert msg.message_id == "111"
|
||||
|
||||
def test_poll_tracks_since_id(self):
|
||||
"""Polling should track since_id to avoid reprocessing tweets."""
|
||||
ch = TwitterChannel(
|
||||
bearer_token="test-bearer",
|
||||
bot_user_id="999",
|
||||
poll_interval=0,
|
||||
)
|
||||
assert ch._since_id is None
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
"data": [
|
||||
{"id": "200", "text": "hello", "author_id": "u1"},
|
||||
{"id": "300", "text": "world", "author_id": "u2"},
|
||||
],
|
||||
}
|
||||
|
||||
ch._stop_event = threading.Event()
|
||||
|
||||
def get_and_stop(*args, **kwargs):
|
||||
ch._stop_event.set()
|
||||
return mock_resp
|
||||
|
||||
with patch("httpx.get", side_effect=get_and_stop):
|
||||
ch._poll_mentions()
|
||||
|
||||
assert ch._since_id == "300"
|
||||
|
||||
def test_poll_empty_response(self):
|
||||
"""Empty mentions response should not error or call handlers."""
|
||||
ch = TwitterChannel(
|
||||
bearer_token="test-bearer",
|
||||
bot_user_id="999",
|
||||
poll_interval=0,
|
||||
)
|
||||
handler = MagicMock()
|
||||
ch.on_message(handler)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"data": []}
|
||||
|
||||
ch._stop_event = threading.Event()
|
||||
|
||||
def get_and_stop(*args, **kwargs):
|
||||
ch._stop_event.set()
|
||||
return mock_resp
|
||||
|
||||
with patch("httpx.get", side_effect=get_and_stop):
|
||||
ch._poll_mentions()
|
||||
|
||||
handler.assert_not_called()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 4. Env var expansion in http_request (GitHub issue creation)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestEnvVarExpansion:
|
||||
"""Verify the http_request tool expands $ENV_VARS in headers."""
|
||||
|
||||
def test_github_token_expanded(self):
|
||||
"""$GITHUB_TOKEN in Authorization header should be expanded."""
|
||||
tool = HttpRequestTool()
|
||||
|
||||
mock_rust = MagicMock()
|
||||
mock_rust.HttpRequestTool.return_value.execute.side_effect = RuntimeError(
|
||||
"mocked",
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 201
|
||||
mock_resp.text = '{"number": 42}'
|
||||
mock_resp.headers = {"content-type": "application/json"}
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_TOKEN": "ghp_test123"}),
|
||||
patch(
|
||||
"openjarvis._rust_bridge.get_rust_module",
|
||||
return_value=mock_rust,
|
||||
),
|
||||
patch("openjarvis.tools.http_request.check_ssrf", return_value=None),
|
||||
patch(
|
||||
"openjarvis.tools.http_request.httpx.request",
|
||||
return_value=mock_resp,
|
||||
) as mock_req,
|
||||
):
|
||||
result = tool.execute(
|
||||
url="https://api.github.com/repos/open-jarvis/OpenJarvis/issues",
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": "Bearer $GITHUB_TOKEN",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
body='{"title": "test", "labels": ["bug"]}',
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
actual_headers = mock_req.call_args[1]["headers"]
|
||||
assert actual_headers["Authorization"] == "Bearer ghp_test123"
|
||||
assert actual_headers["Accept"] == "application/vnd.github+json"
|
||||
|
||||
def test_unexpanded_var_without_env(self):
|
||||
"""$GITHUB_TOKEN without env var set should remain as literal."""
|
||||
tool = HttpRequestTool()
|
||||
|
||||
mock_rust = MagicMock()
|
||||
mock_rust.HttpRequestTool.return_value.execute.side_effect = RuntimeError(
|
||||
"mocked",
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 401
|
||||
mock_resp.text = "Bad credentials"
|
||||
mock_resp.headers = {"content-type": "text/plain"}
|
||||
|
||||
env = {k: v for k, v in os.environ.items() if k != "GITHUB_TOKEN"}
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch(
|
||||
"openjarvis._rust_bridge.get_rust_module",
|
||||
return_value=mock_rust,
|
||||
),
|
||||
patch("openjarvis.tools.http_request.check_ssrf", return_value=None),
|
||||
patch(
|
||||
"openjarvis.tools.http_request.httpx.request",
|
||||
return_value=mock_resp,
|
||||
) as mock_req,
|
||||
):
|
||||
tool.execute(
|
||||
url="https://api.github.com/repos/test/test/issues",
|
||||
method="POST",
|
||||
headers={"Authorization": "Bearer $GITHUB_TOKEN"},
|
||||
body="{}",
|
||||
)
|
||||
|
||||
actual_headers = mock_req.call_args[1]["headers"]
|
||||
assert actual_headers["Authorization"] == "Bearer $GITHUB_TOKEN"
|
||||
|
||||
def test_non_string_header_values_pass_through(self):
|
||||
"""Non-string header values should pass through without error."""
|
||||
tool = HttpRequestTool()
|
||||
|
||||
mock_rust = MagicMock()
|
||||
mock_rust.HttpRequestTool.return_value.execute.side_effect = RuntimeError(
|
||||
"mocked",
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.text = "ok"
|
||||
mock_resp.headers = {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"openjarvis._rust_bridge.get_rust_module",
|
||||
return_value=mock_rust,
|
||||
),
|
||||
patch("openjarvis.tools.http_request.check_ssrf", return_value=None),
|
||||
patch(
|
||||
"openjarvis.tools.http_request.httpx.request",
|
||||
return_value=mock_resp,
|
||||
) as mock_req,
|
||||
):
|
||||
tool.execute(
|
||||
url="https://example.com",
|
||||
headers={"X-Count": 42, "X-Name": "test"},
|
||||
)
|
||||
|
||||
actual_headers = mock_req.call_args[1]["headers"]
|
||||
assert actual_headers["X-Count"] == 42
|
||||
assert actual_headers["X-Name"] == "test"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 5. Full reactive e2e flow (mock Jarvis + TwitterChannel)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestFullE2EFlow:
|
||||
"""Test the full flow: mention arrives → classify → prompt → agent → tool calls."""
|
||||
|
||||
def _make_mock_jarvis(self, responses=None):
|
||||
"""Create a mock Jarvis instance that returns canned responses."""
|
||||
j = MagicMock()
|
||||
if responses:
|
||||
j.ask.side_effect = responses
|
||||
else:
|
||||
j.ask.return_value = "mock response"
|
||||
return j
|
||||
|
||||
def test_question_flow(self):
|
||||
"""Question mention → retrieval runs in Python, agent only needs channel_send.
|
||||
|
||||
After the dense-retrieval refactor, ``memory_search`` is no longer
|
||||
a model-visible tool; retrieval happens out-of-band and the score
|
||||
picks between grounded/deferral prompts. The only tool the agent
|
||||
needs for a QUESTION is ``channel_send``.
|
||||
"""
|
||||
j = self._make_mock_jarvis(["check the docs at open-jarvis.github.io"])
|
||||
tweet = DEMO_TWEETS[0]
|
||||
|
||||
mention_type = _classify_mention(tweet["text"])
|
||||
assert mention_type == "QUESTION"
|
||||
|
||||
prompt = _build_question_deferral_prompt(
|
||||
tweet["author"], tweet["id"], tweet["text"]
|
||||
)
|
||||
j.ask(
|
||||
prompt,
|
||||
agent="orchestrator",
|
||||
tools=["channel_send"],
|
||||
temperature=0.4,
|
||||
)
|
||||
|
||||
j.ask.assert_called_once()
|
||||
call_kwargs = j.ask.call_args
|
||||
assert "channel_send" in call_kwargs[1]["tools"]
|
||||
# memory_search is explicitly NOT passed — retrieval was already done
|
||||
assert "memory_search" not in call_kwargs[1]["tools"]
|
||||
assert call_kwargs[1]["agent"] == "orchestrator"
|
||||
|
||||
def test_bug_report_flow(self):
|
||||
"""Bug mention → http_request (GitHub issue) + channel_send."""
|
||||
j = self._make_mock_jarvis(["opened an issue for this"])
|
||||
tweet = DEMO_TWEETS[1]
|
||||
|
||||
mention_type = _classify_mention(tweet["text"])
|
||||
assert mention_type == "BUG_REPORT"
|
||||
|
||||
prompt = _build_bug_prompt(tweet["author"], tweet["id"], tweet["text"])
|
||||
j.ask(
|
||||
prompt,
|
||||
agent="orchestrator",
|
||||
tools=["http_request", "channel_send"],
|
||||
temperature=0.4,
|
||||
)
|
||||
|
||||
call_kwargs = j.ask.call_args
|
||||
assert "http_request" in call_kwargs[1]["tools"]
|
||||
assert "channel_send" in call_kwargs[1]["tools"]
|
||||
assert "api.github.com" in call_kwargs[0][0]
|
||||
assert "bug" in call_kwargs[0][0]
|
||||
|
||||
def test_feature_request_flow(self):
|
||||
"""Feature mention → http_request (GitHub issue) + channel_send."""
|
||||
j = self._make_mock_jarvis(
|
||||
["love this idea — opened an issue to track it"],
|
||||
)
|
||||
tweet = DEMO_TWEETS[2]
|
||||
|
||||
mention_type = _classify_mention(tweet["text"])
|
||||
assert mention_type == "FEATURE_REQUEST"
|
||||
|
||||
prompt = _build_feature_prompt(
|
||||
tweet["author"], tweet["id"], tweet["text"],
|
||||
)
|
||||
j.ask(
|
||||
prompt,
|
||||
agent="orchestrator",
|
||||
tools=["http_request", "channel_send"],
|
||||
temperature=0.4,
|
||||
)
|
||||
|
||||
call_kwargs = j.ask.call_args
|
||||
assert "http_request" in call_kwargs[1]["tools"]
|
||||
assert "enhancement" in call_kwargs[0][0]
|
||||
|
||||
def test_praise_flow(self):
|
||||
"""Praise mention → channel_send only."""
|
||||
j = self._make_mock_jarvis(["thanks, glad you like it!"])
|
||||
tweet = DEMO_TWEETS[3]
|
||||
|
||||
mention_type = _classify_mention(tweet["text"])
|
||||
assert mention_type == "PRAISE"
|
||||
|
||||
prompt = _build_praise_prompt(tweet["author"], tweet["id"], tweet["text"])
|
||||
j.ask(prompt, agent="orchestrator", tools=["channel_send"], temperature=0.4)
|
||||
|
||||
call_kwargs = j.ask.call_args
|
||||
assert call_kwargs[1]["tools"] == ["channel_send"]
|
||||
|
||||
def test_spam_is_ignored(self):
|
||||
"""Spam mentions should be skipped — no Jarvis.ask call."""
|
||||
j = self._make_mock_jarvis()
|
||||
tweet = DEMO_TWEETS[4]
|
||||
|
||||
mention_type = _classify_mention(tweet["text"])
|
||||
assert mention_type == "SPAM"
|
||||
|
||||
if mention_type != "SPAM":
|
||||
j.ask("should not be called")
|
||||
|
||||
j.ask.assert_not_called()
|
||||
|
||||
def test_all_demo_tweets_processed(self):
|
||||
"""Run each demo tweet; verify classification + tool selection.
|
||||
|
||||
Post dense-retrieval refactor: QUESTIONs no longer request
|
||||
``memory_search`` as a tool — retrieval is done in Python.
|
||||
"""
|
||||
expected = [
|
||||
("QUESTION", ["channel_send"]),
|
||||
("BUG_REPORT", ["http_request", "channel_send"]),
|
||||
("FEATURE_REQUEST", ["http_request", "channel_send"]),
|
||||
("PRAISE", ["channel_send"]),
|
||||
("SPAM", None),
|
||||
]
|
||||
|
||||
for tweet, (exp_type, exp_tools) in zip(DEMO_TWEETS, expected):
|
||||
mention_type = _classify_mention(tweet["text"])
|
||||
assert mention_type == exp_type, f"Tweet by {tweet['author']} misclassified"
|
||||
|
||||
if mention_type == "SPAM":
|
||||
continue
|
||||
|
||||
if mention_type == "QUESTION":
|
||||
tools = ["channel_send"]
|
||||
elif mention_type == "BUG_REPORT":
|
||||
tools = ["http_request", "channel_send"]
|
||||
elif mention_type == "FEATURE_REQUEST":
|
||||
tools = ["http_request", "channel_send"]
|
||||
else:
|
||||
tools = ["channel_send"]
|
||||
|
||||
assert tools == exp_tools, f"Wrong tools for {tweet['author']}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 6. Send-as-reply (conversation_id passthrough)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestReplyConversationId:
|
||||
"""Verify that replies always pass conversation_id back to the Twitter API."""
|
||||
|
||||
def test_send_passes_conversation_id_as_reply(self):
|
||||
ch = TwitterChannel(
|
||||
bearer_token="b",
|
||||
api_key="ck",
|
||||
api_secret="cs",
|
||||
access_token="at",
|
||||
access_secret="as",
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 201
|
||||
|
||||
with patch("httpx.post", return_value=mock_resp) as mock_post:
|
||||
ch.send(
|
||||
"twitter",
|
||||
"opened an issue for this — we'll look into it",
|
||||
conversation_id="1000000000000000002",
|
||||
)
|
||||
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
assert payload["reply"]["in_reply_to_tweet_id"] == "1000000000000000002"
|
||||
assert len(payload["text"]) <= 280
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 7. GitHub issue creation e2e (http_request with expanded token)
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestGitHubIssueCreation:
|
||||
"""Simulate the LLM calling http_request to create a GitHub issue."""
|
||||
|
||||
def test_create_bug_issue(self):
|
||||
tool = HttpRequestTool()
|
||||
|
||||
mock_rust = MagicMock()
|
||||
mock_rust.HttpRequestTool.return_value.execute.side_effect = (
|
||||
RuntimeError("mocked")
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 201
|
||||
mock_resp.text = json.dumps({
|
||||
"number": 42,
|
||||
"html_url": "https://github.com/open-jarvis/OpenJarvis/issues/42",
|
||||
})
|
||||
mock_resp.headers = {"content-type": "application/json"}
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_TOKEN": "ghp_testtoken123"}),
|
||||
patch("openjarvis._rust_bridge.get_rust_module", return_value=mock_rust),
|
||||
patch("openjarvis.tools.http_request.check_ssrf", return_value=None),
|
||||
patch(
|
||||
"openjarvis.tools.http_request.httpx.request",
|
||||
return_value=mock_resp,
|
||||
) as mock_req,
|
||||
):
|
||||
result = tool.execute(
|
||||
url="https://api.github.com/repos/open-jarvis/OpenJarvis/issues",
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": "Bearer $GITHUB_TOKEN",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
body=json.dumps({
|
||||
"title": "memory_search tool crashes on empty index",
|
||||
"body": (
|
||||
"reported via twitter by @bob_user: bug: the "
|
||||
"memory_search tool crashes when the index is empty"
|
||||
),
|
||||
"labels": ["bug", "from-twitter"],
|
||||
}),
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert "42" in result.content
|
||||
|
||||
actual_call = mock_req.call_args
|
||||
assert actual_call[0][0] == "POST"
|
||||
assert "api.github.com" in actual_call[0][1]
|
||||
assert (
|
||||
actual_call[1]["headers"]["Authorization"]
|
||||
== "Bearer ghp_testtoken123"
|
||||
)
|
||||
|
||||
body = actual_call[1]["content"]
|
||||
parsed_body = json.loads(body)
|
||||
assert parsed_body["labels"] == ["bug", "from-twitter"]
|
||||
assert "bob_user" in parsed_body["body"]
|
||||
|
||||
def test_create_feature_issue(self):
|
||||
tool = HttpRequestTool()
|
||||
|
||||
mock_rust = MagicMock()
|
||||
mock_rust.HttpRequestTool.return_value.execute.side_effect = (
|
||||
RuntimeError("mocked")
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 201
|
||||
mock_resp.text = json.dumps({"number": 43})
|
||||
mock_resp.headers = {"content-type": "application/json"}
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_TOKEN": "ghp_testtoken123"}),
|
||||
patch("openjarvis._rust_bridge.get_rust_module", return_value=mock_rust),
|
||||
patch("openjarvis.tools.http_request.check_ssrf", return_value=None),
|
||||
patch(
|
||||
"openjarvis.tools.http_request.httpx.request",
|
||||
return_value=mock_resp,
|
||||
) as mock_req,
|
||||
):
|
||||
result = tool.execute(
|
||||
url="https://api.github.com/repos/open-jarvis/OpenJarvis/issues",
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": "Bearer $GITHUB_TOKEN",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
body=json.dumps({
|
||||
"title": "feature request: built-in scheduler UI",
|
||||
"body": (
|
||||
"requested via twitter by @carol_eng: it would "
|
||||
"be great to have a built-in scheduler UI"
|
||||
),
|
||||
"labels": ["enhancement", "from-twitter"],
|
||||
}),
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
body = json.loads(mock_req.call_args[1]["content"])
|
||||
assert body["labels"] == ["enhancement", "from-twitter"]
|
||||
assert "carol_eng" in body["body"]
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Tests for the TwitterChannel adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.channels._stubs import ChannelStatus
|
||||
from openjarvis.channels.twitter_channel import TwitterChannel
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _register_twitter():
|
||||
"""Re-register after any registry clear."""
|
||||
if not ChannelRegistry.contains("twitter"):
|
||||
ChannelRegistry.register_value("twitter", TwitterChannel)
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_registry_key(self):
|
||||
assert ChannelRegistry.contains("twitter")
|
||||
|
||||
def test_channel_id(self):
|
||||
ch = TwitterChannel(bearer_token="test-bearer")
|
||||
assert ch.channel_id == "twitter"
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_defaults(self):
|
||||
ch = TwitterChannel()
|
||||
assert ch._bearer == ""
|
||||
assert ch._api_key == ""
|
||||
assert ch._status == ChannelStatus.DISCONNECTED
|
||||
|
||||
def test_constructor_bearer(self):
|
||||
ch = TwitterChannel(bearer_token="my-bearer")
|
||||
assert ch._bearer == "my-bearer"
|
||||
|
||||
def test_env_var_fallback(self):
|
||||
env = {
|
||||
"TWITTER_BEARER_TOKEN": "env-bearer",
|
||||
"TWITTER_API_KEY": "env-key",
|
||||
"TWITTER_API_SECRET": "env-secret",
|
||||
"TWITTER_ACCESS_TOKEN": "env-access",
|
||||
"TWITTER_ACCESS_SECRET": "env-acc-secret",
|
||||
"TWITTER_BOT_USER_ID": "12345",
|
||||
}
|
||||
with patch.dict(os.environ, env):
|
||||
ch = TwitterChannel()
|
||||
assert ch._bearer == "env-bearer"
|
||||
assert ch._api_key == "env-key"
|
||||
assert ch._api_secret == "env-secret"
|
||||
assert ch._access_token == "env-access"
|
||||
assert ch._access_secret == "env-acc-secret"
|
||||
assert ch._bot_user_id == "12345"
|
||||
|
||||
def test_constructor_overrides_env(self):
|
||||
with patch.dict(os.environ, {"TWITTER_BEARER_TOKEN": "env-bearer"}):
|
||||
ch = TwitterChannel(bearer_token="explicit-bearer")
|
||||
assert ch._bearer == "explicit-bearer"
|
||||
|
||||
|
||||
class TestSend:
|
||||
def test_send_success(self):
|
||||
ch = TwitterChannel(
|
||||
bearer_token="b",
|
||||
api_key="ck",
|
||||
api_secret="cs",
|
||||
access_token="at",
|
||||
access_secret="as",
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
|
||||
with patch("httpx.post", return_value=mock_response) as mock_post:
|
||||
result = ch.send("twitter", "Hello from OpenJarvis!")
|
||||
assert result is True
|
||||
mock_post.assert_called_once()
|
||||
call_args = mock_post.call_args
|
||||
url = call_args[0][0]
|
||||
assert "api.twitter.com/2/tweets" in url
|
||||
payload = call_args[1]["json"]
|
||||
assert payload["text"] == "Hello from OpenJarvis!"
|
||||
assert "reply" not in payload
|
||||
|
||||
def test_send_as_reply(self):
|
||||
ch = TwitterChannel(
|
||||
bearer_token="b",
|
||||
api_key="ck",
|
||||
api_secret="cs",
|
||||
access_token="at",
|
||||
access_secret="as",
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
|
||||
with patch("httpx.post", return_value=mock_response) as mock_post:
|
||||
result = ch.send(
|
||||
"twitter", "Replying!", conversation_id="9876543210",
|
||||
)
|
||||
assert result is True
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
assert payload["reply"]["in_reply_to_tweet_id"] == "9876543210"
|
||||
|
||||
def test_send_truncates_to_280(self):
|
||||
ch = TwitterChannel(
|
||||
bearer_token="b",
|
||||
api_key="ck",
|
||||
api_secret="cs",
|
||||
access_token="at",
|
||||
access_secret="as",
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
|
||||
long_text = "A" * 300
|
||||
with patch("httpx.post", return_value=mock_response) as mock_post:
|
||||
ch.send("twitter", long_text)
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
assert len(payload["text"]) == 280
|
||||
|
||||
def test_send_failure(self):
|
||||
ch = TwitterChannel(
|
||||
bearer_token="b",
|
||||
api_key="ck",
|
||||
api_secret="cs",
|
||||
access_token="at",
|
||||
access_secret="as",
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 403
|
||||
mock_response.text = "Forbidden"
|
||||
|
||||
with patch("httpx.post", return_value=mock_response):
|
||||
result = ch.send("twitter", "Hello!")
|
||||
assert result is False
|
||||
|
||||
def test_send_exception(self):
|
||||
ch = TwitterChannel(
|
||||
bearer_token="b",
|
||||
api_key="ck",
|
||||
api_secret="cs",
|
||||
access_token="at",
|
||||
access_secret="as",
|
||||
)
|
||||
|
||||
with patch("httpx.post", side_effect=ConnectionError("refused")):
|
||||
result = ch.send("twitter", "Hello!")
|
||||
assert result is False
|
||||
|
||||
def test_send_no_credentials(self):
|
||||
ch = TwitterChannel()
|
||||
result = ch.send("twitter", "Hello!")
|
||||
assert result is False
|
||||
|
||||
def test_send_publishes_event(self):
|
||||
bus = EventBus(record_history=True)
|
||||
ch = TwitterChannel(
|
||||
bearer_token="b",
|
||||
api_key="ck",
|
||||
api_secret="cs",
|
||||
access_token="at",
|
||||
access_secret="as",
|
||||
bus=bus,
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
|
||||
with patch("httpx.post", return_value=mock_response):
|
||||
ch.send("twitter", "Hello!")
|
||||
|
||||
event_types = [e.event_type for e in bus.history]
|
||||
assert EventType.CHANNEL_MESSAGE_SENT in event_types
|
||||
|
||||
|
||||
class TestListChannels:
|
||||
def test_list_channels(self):
|
||||
ch = TwitterChannel(bearer_token="b")
|
||||
assert ch.list_channels() == ["twitter"]
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_disconnected_initially(self):
|
||||
ch = TwitterChannel(bearer_token="b")
|
||||
assert ch.status() == ChannelStatus.DISCONNECTED
|
||||
|
||||
def test_no_token_connect_error(self):
|
||||
ch = TwitterChannel()
|
||||
ch.connect()
|
||||
assert ch.status() == ChannelStatus.ERROR
|
||||
|
||||
|
||||
class TestOnMessage:
|
||||
def test_on_message(self):
|
||||
ch = TwitterChannel(bearer_token="b")
|
||||
handler = MagicMock()
|
||||
ch.on_message(handler)
|
||||
assert handler in ch._handlers
|
||||
|
||||
|
||||
class TestDisconnect:
|
||||
def test_disconnect(self):
|
||||
ch = TwitterChannel(bearer_token="b")
|
||||
ch._status = ChannelStatus.CONNECTED
|
||||
ch.disconnect()
|
||||
assert ch.status() == ChannelStatus.DISCONNECTED
|
||||
@@ -23,11 +23,18 @@ def test_ask_has_no_context_option():
|
||||
assert "--no-context" in result.output
|
||||
|
||||
|
||||
def test_get_memory_backend_returns_none_when_empty(
|
||||
def test_get_memory_backend_returns_backend_even_when_empty(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""_get_memory_backend returns None when no docs indexed."""
|
||||
"""_get_memory_backend returns a backend for an empty DB.
|
||||
|
||||
Retrieval against an empty store is a valid operation — it simply
|
||||
returns no hits. Callers must check ``len(results)``; returning
|
||||
``None`` here would conflate "backend unavailable" with "no docs",
|
||||
which is the kind of ambiguity that leads to silent grounding
|
||||
failures downstream.
|
||||
"""
|
||||
from openjarvis.core.config import JarvisConfig, MemoryConfig
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
from openjarvis.tools.storage.sqlite import SQLiteMemory
|
||||
@@ -42,7 +49,11 @@ def test_get_memory_backend_returns_none_when_empty(
|
||||
|
||||
mod = importlib.import_module("openjarvis.cli.ask")
|
||||
result = mod._get_memory_backend(config)
|
||||
assert result is None
|
||||
assert result is not None
|
||||
# An empty backend should still retrieve cleanly (zero hits).
|
||||
assert result.retrieve("anything", top_k=3) == []
|
||||
if hasattr(result, "close"):
|
||||
result.close()
|
||||
|
||||
|
||||
def test_get_memory_backend_returns_backend_with_docs(
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
# Channel Integrations
|
||||
|
||||
OpenJarvis ships adapters for many messaging platforms.
|
||||
|
||||
## Telegram
|
||||
|
||||
Set `TELEGRAM_BOT_TOKEN` in your env and call `TelegramChannel().connect()`. Incoming messages are delivered via the `on_message` callback.
|
||||
|
||||
## Slack
|
||||
|
||||
Install the `channel-slack` extra and provide a bot token starting with `xoxb-`. Slack uses socket mode by default but HTTP event mode is also supported.
|
||||
|
||||
## Discord
|
||||
|
||||
Discord needs a bot token and intents configured for message content. Add the bot to your server first, then register the channel.
|
||||
|
||||
## Adding a New Channel
|
||||
|
||||
Create a subclass of `BaseChannel` in `src/openjarvis/channels/your_channel.py`. Decorate with `@ChannelRegistry.register("name")`. Implement `connect`, `disconnect`, `send`, `status`, `list_channels`, and `on_message`.
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
# Inference Engines
|
||||
|
||||
OpenJarvis supports several inference backends.
|
||||
|
||||
## Ollama
|
||||
|
||||
The default on consumer hardware. Runs locally and exposes an HTTP API. Good for GGUF-quantized models.
|
||||
|
||||
## vLLM
|
||||
|
||||
High-throughput serving backend with tensor parallelism. Best for NVIDIA GPUs with plenty of VRAM. Supports prefix caching and continuous batching.
|
||||
|
||||
## llama.cpp
|
||||
|
||||
Pure C++ inference. Runs on CPU, Metal, CUDA, or ROCm. Ideal for laptops without a discrete GPU.
|
||||
|
||||
## Picking an Engine
|
||||
|
||||
Run `jarvis init` — it detects your hardware and recommends the best fit. You can override with `--engine <name>`.
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
# Hardware Requirements
|
||||
|
||||
OpenJarvis auto-detects your hardware and picks a sensible model.
|
||||
|
||||
## GPU Acceleration
|
||||
|
||||
On NVIDIA GPUs OpenJarvis uses CUDA via vLLM or Ollama. On Apple Silicon it uses Metal via the MLX engine. AMD GPUs work through ROCm when vLLM is available.
|
||||
|
||||
## Running Without a GPU
|
||||
|
||||
Yes, OpenJarvis supports CPU-only mode. Use llama.cpp as the engine and pick a small model — the 4B model is recommended for speed on CPU. Larger models will load but tokens-per-second drops significantly.
|
||||
|
||||
## Memory
|
||||
|
||||
Rough guide for model memory footprint at Q4 quantization:
|
||||
- 4B model: ~3 GB
|
||||
- 8B model: ~5 GB
|
||||
- 30B model: ~20 GB
|
||||
|
||||
Add roughly 2 GB for OS and context.
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
# Memory and Retrieval
|
||||
|
||||
OpenJarvis stores facts the agent has learned so it can retrieve them later.
|
||||
|
||||
## Backends
|
||||
|
||||
The default is SQLite with FTS5 for keyword search. BM25, FAISS (dense vector), and Hybrid (BM25 + dense) backends are also available.
|
||||
|
||||
## Conflicting Facts
|
||||
|
||||
When you store "X is true" today and "X is false" tomorrow, the memory system keeps both records with timestamps. Retrieval is recency-biased by default so the latest fact wins, but older versions remain queryable.
|
||||
|
||||
## Deleting Memories
|
||||
|
||||
Call `backend.delete(doc_id)` to remove a single record, or `backend.clear()` to wipe everything. There is currently no undo.
|
||||
@@ -0,0 +1,476 @@
|
||||
"""Tests for the DenseMemory backend.
|
||||
|
||||
These tests exercise retrieval quality on a small fixture corpus, then
|
||||
assert on the actual cosine-similarity score distribution the embedding
|
||||
model produces. The thresholds here are set **empirically** from the
|
||||
observed scores on nomic-embed-text — not guessed upfront — so a
|
||||
regression in either the embedder or the chunker will show up as a
|
||||
failing assertion rather than a silently bad result.
|
||||
|
||||
The tests require Ollama with ``nomic-embed-text`` pulled; they are
|
||||
skipped if the server is unreachable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.tools.storage.dense import (
|
||||
DenseMemory,
|
||||
MdChunk,
|
||||
chunk_markdown,
|
||||
dedupe_chunks,
|
||||
)
|
||||
|
||||
_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "fixtures" / "docs"
|
||||
_OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "localhost")
|
||||
_OLLAMA_PORT = int(os.environ.get("OLLAMA_PORT", "11434"))
|
||||
|
||||
|
||||
def _ollama_up() -> bool:
|
||||
try:
|
||||
with socket.create_connection((_OLLAMA_HOST, _OLLAMA_PORT), timeout=1.0):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
ollama_required = pytest.mark.skipif(
|
||||
not _ollama_up(),
|
||||
reason=(
|
||||
"Requires Ollama with nomic-embed-text "
|
||||
"(start `ollama serve` then `ollama pull nomic-embed-text`)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chunking unit tests (no Ollama required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChunkMarkdown:
|
||||
def test_empty_text(self):
|
||||
assert chunk_markdown("") == []
|
||||
assert chunk_markdown(" \n\n ") == []
|
||||
|
||||
def test_single_section_without_splits(self):
|
||||
md = (
|
||||
"# Title\n\n"
|
||||
"Some body paragraph with a few sentences. Enough to be a chunk."
|
||||
)
|
||||
chunks = chunk_markdown(md, source="t.md")
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].breadcrumb == "Title"
|
||||
assert "body paragraph" in chunks[0].content
|
||||
|
||||
def test_splits_on_h2_and_h3(self):
|
||||
md = (
|
||||
"# Book\n\n"
|
||||
"## Chapter One\n\nfirst body.\n\n"
|
||||
"### Section A\n\nalpha body.\n\n"
|
||||
"### Section B\n\nbeta body.\n\n"
|
||||
"## Chapter Two\n\ngamma body.\n"
|
||||
)
|
||||
chunks = chunk_markdown(md, source="t.md")
|
||||
breadcrumbs = [c.breadcrumb for c in chunks]
|
||||
# Every header change becomes a new chunk
|
||||
assert "Book > Chapter One" in breadcrumbs
|
||||
assert "Book > Chapter One > Section A" in breadcrumbs
|
||||
assert "Book > Chapter One > Section B" in breadcrumbs
|
||||
assert "Book > Chapter Two" in breadcrumbs
|
||||
|
||||
def test_ignores_headers_inside_code_fences(self):
|
||||
# Inside a fenced code block, lines starting with '#' are
|
||||
# shell/python comments, not markdown headers.
|
||||
md = (
|
||||
"# Guide\n\n"
|
||||
"## Install\n\n"
|
||||
"Run these commands:\n\n"
|
||||
"```bash\n"
|
||||
"# Install Homebrew\n"
|
||||
"# Build the Rust extension\n"
|
||||
"brew install rust\n"
|
||||
"```\n\n"
|
||||
"Then verify.\n"
|
||||
)
|
||||
chunks = chunk_markdown(md, source="t.md")
|
||||
breadcrumbs = [c.breadcrumb for c in chunks]
|
||||
assert "Guide > Install" in breadcrumbs
|
||||
# Must NOT have parsed the shell comments as headers:
|
||||
assert not any("Install Homebrew" in b for b in breadcrumbs)
|
||||
assert not any("Build the Rust extension" in b for b in breadcrumbs)
|
||||
|
||||
def test_oversize_section_is_split_with_overlap(self):
|
||||
body = " ".join(["word"] * 2500)
|
||||
md = f"# Big\n\n## Section\n\n{body}\n"
|
||||
chunks = chunk_markdown(
|
||||
md,
|
||||
source="t.md",
|
||||
max_section_tokens=500,
|
||||
paragraph_overlap_tokens=50,
|
||||
)
|
||||
assert len(chunks) >= 2
|
||||
for c in chunks:
|
||||
# Every chunk carries the breadcrumb
|
||||
assert c.breadcrumb == "Big > Section"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-file deduplication (no Ollama required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mk(content: str, source: str) -> MdChunk:
|
||||
return MdChunk(content=f"Header\n\n{content}", source=source, breadcrumb="Header")
|
||||
|
||||
|
||||
class TestDedupeChunks:
|
||||
def test_no_dedupe_when_under_threshold_files(self):
|
||||
"""Two identical chunks across two files should NOT be deduped (need 3+)."""
|
||||
chunks = [
|
||||
_mk("the quick brown fox jumps over the lazy dog every morning", "a.md"),
|
||||
_mk("the quick brown fox jumps over the lazy dog every morning", "b.md"),
|
||||
]
|
||||
survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
|
||||
assert len(survivors) == 2
|
||||
assert report.removed_count == 0
|
||||
|
||||
def test_dedupes_boilerplate_across_three_files(self):
|
||||
"""Same blurb in 3+ files → keep one canonical, drop the rest."""
|
||||
body = "openjarvis runs entirely on your hardware no cloud needed local first"
|
||||
chunks = [
|
||||
_mk(body, "docs/index.md"),
|
||||
_mk(body, "docs/downloads.md"),
|
||||
_mk(body, "docs/getting-started/installation.md"),
|
||||
]
|
||||
survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
|
||||
assert len(survivors) == 1
|
||||
# Most-specific source path wins (deepest)
|
||||
assert survivors[0].source == "docs/getting-started/installation.md"
|
||||
assert report.removed_count == 2
|
||||
assert len(report.groups) == 1
|
||||
grp = report.groups[0]
|
||||
assert grp.distinct_files == 3
|
||||
assert "docs/index.md" in grp.dropped_sources
|
||||
assert "docs/downloads.md" in grp.dropped_sources
|
||||
|
||||
def test_keeps_distinct_content(self):
|
||||
"""Genuinely different chunks must survive even with shared phrases."""
|
||||
chunks = [
|
||||
_mk(
|
||||
"install ollama with brew install ollama then run ollama serve",
|
||||
"a.md",
|
||||
),
|
||||
_mk(
|
||||
"configure vllm with tensor parallelism and prefix caching",
|
||||
"b.md",
|
||||
),
|
||||
_mk(
|
||||
"llama.cpp builds with cmake and supports cpu metal cuda rocm",
|
||||
"c.md",
|
||||
),
|
||||
]
|
||||
survivors, report = dedupe_chunks(chunks)
|
||||
assert len(survivors) == 3
|
||||
assert report.removed_count == 0
|
||||
|
||||
def test_minor_edit_is_clustered_when_body_is_long_enough(self):
|
||||
"""A one-word swap in a long boilerplate paragraph still clusters.
|
||||
|
||||
At 5-grams a one-word change kills 5 n-grams; in a short 14-word
|
||||
sentence that's half the grams (Jaccard ~0.33 — below the 0.7
|
||||
threshold), but in real boilerplate paragraphs the change is a
|
||||
small fraction of total grams and the cluster still forms. This
|
||||
test uses a paragraph long enough to put Jaccard above 0.7.
|
||||
"""
|
||||
common = (
|
||||
"openjarvis is a personal ai platform that runs entirely on your "
|
||||
"own hardware no cloud apis required by default the project is "
|
||||
"open source apache 2 licensed and supports ollama vllm sglang "
|
||||
"and llama cpp inference engines with auto detection of your "
|
||||
"available compute resources at startup so the right backend is "
|
||||
"picked without manual configuration in most cases"
|
||||
)
|
||||
# One-word change shouldn't break clustering on this length
|
||||
a = common + " choose the interface that suits you"
|
||||
b = common + " pick the interface that suits you"
|
||||
c = common + " select the interface that suits you"
|
||||
chunks = [_mk(a, "a.md"), _mk(b, "b.md"), _mk(c, "c.md")]
|
||||
survivors, report = dedupe_chunks(chunks)
|
||||
assert len(survivors) == 1, (
|
||||
f"got {len(survivors)} survivors — "
|
||||
"expected single cluster from boilerplate"
|
||||
)
|
||||
assert report.removed_count == 2
|
||||
|
||||
def test_breadcrumb_difference_does_not_block_dedupe(self):
|
||||
"""Identical body wrapped in different breadcrumbs still dedupes.
|
||||
|
||||
Without stripping the breadcrumb prefix before n-gram extraction,
|
||||
chunks with the same body but different leading words (e.g.
|
||||
``Downloads`` vs ``Installation``) would have lower Jaccard.
|
||||
"""
|
||||
body = (
|
||||
"openjarvis runs entirely on your hardware no cloud needed "
|
||||
"local first foundation"
|
||||
)
|
||||
chunks = [
|
||||
MdChunk(
|
||||
content=f"Downloads\n\n{body}",
|
||||
source="docs/downloads.md",
|
||||
breadcrumb="Downloads",
|
||||
),
|
||||
MdChunk(
|
||||
content=f"Installation\n\n{body}",
|
||||
source="docs/install.md",
|
||||
breadcrumb="Installation",
|
||||
),
|
||||
MdChunk(
|
||||
content=f"Welcome\n\n{body}",
|
||||
source="docs/index.md",
|
||||
breadcrumb="Welcome",
|
||||
),
|
||||
]
|
||||
survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
|
||||
assert len(survivors) == 1, (
|
||||
f"got {len(survivors)} survivors: {[s.source for s in survivors]}"
|
||||
)
|
||||
assert report.removed_count == 2
|
||||
|
||||
def test_path_specificity_tiebreaker(self):
|
||||
"""When duplicates exist, the deepest path wins."""
|
||||
body = (
|
||||
"we use the orchestrator agent backed by tools and memory "
|
||||
"backends configured per recipe"
|
||||
)
|
||||
chunks = [
|
||||
_mk(body, "shallow.md"),
|
||||
_mk(body, "docs/middle.md"),
|
||||
_mk(body, "docs/getting-started/installation.md"),
|
||||
]
|
||||
survivors, _ = dedupe_chunks(chunks, min_files_for_dup=3)
|
||||
assert len(survivors) == 1
|
||||
assert survivors[0].source == "docs/getting-started/installation.md"
|
||||
|
||||
def test_empty_input(self):
|
||||
survivors, report = dedupe_chunks([])
|
||||
assert survivors == []
|
||||
assert report.input_count == 0
|
||||
assert report.output_count == 0
|
||||
|
||||
def test_does_not_remove_more_than_corpus(self):
|
||||
"""Sanity: removed_count <= input_count, output_count >= 1 per cluster."""
|
||||
body = "boilerplate about how openjarvis runs entirely on your hardware locally"
|
||||
chunks = [_mk(body, f"f{i}.md") for i in range(10)]
|
||||
survivors, report = dedupe_chunks(chunks, min_files_for_dup=3)
|
||||
# 10 files all duplicates → 1 survives
|
||||
assert len(survivors) == 1
|
||||
assert report.removed_count == 9
|
||||
assert report.output_count + report.removed_count == report.input_count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retrieval quality tests (require Ollama + nomic-embed-text)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def indexed_backend():
|
||||
"""DenseMemory populated from the fixture corpus once per module."""
|
||||
if not _ollama_up():
|
||||
pytest.skip("Ollama not reachable")
|
||||
|
||||
backend = DenseMemory()
|
||||
md_files = sorted(_FIXTURE_DIR.glob("*.md"))
|
||||
assert md_files, f"no fixtures at {_FIXTURE_DIR}"
|
||||
|
||||
contents, sources, metadatas = [], [], []
|
||||
for f in md_files:
|
||||
for chunk in chunk_markdown(f.read_text(encoding="utf-8"), source=f.name):
|
||||
contents.append(chunk.content)
|
||||
sources.append(chunk.source)
|
||||
metadatas.append({"breadcrumb": chunk.breadcrumb})
|
||||
backend.store_many(contents, sources=sources, metadatas=metadatas)
|
||||
return backend
|
||||
|
||||
|
||||
@ollama_required
|
||||
def test_index_built_with_expected_chunk_count(indexed_backend):
|
||||
# 4 fixture files with small sections should give a modest chunk count —
|
||||
# not 1 (indexing broken), not 100 (splitting gone wild).
|
||||
n = indexed_backend.count()
|
||||
assert 8 <= n <= 25, f"unexpected chunk count: {n}"
|
||||
|
||||
|
||||
# Threshold chosen empirically from the score distribution documented in
|
||||
# test_score_distribution_vs_threshold below. See twitter_bot.py for the
|
||||
# shared constant used at runtime.
|
||||
_SCORE_THRESHOLD = 0.55
|
||||
|
||||
|
||||
@ollama_required
|
||||
def test_exact_match_is_top_hit(indexed_backend):
|
||||
"""A query lifted verbatim from the corpus should return that chunk at rank 1.
|
||||
|
||||
We avoid generic terms like "backends" (which match both memory.md
|
||||
and engines.md) and use a phrase that's distinctive in one doc.
|
||||
"""
|
||||
results = indexed_backend.retrieve("BM25 and FAISS for memory retrieval", top_k=3)
|
||||
assert results, "expected at least one hit"
|
||||
assert results[0].source == "memory.md"
|
||||
assert "bm25" in results[0].content.lower() or "faiss" in results[0].content.lower()
|
||||
# Verbatim-ish matches against nomic-embed-text cluster around 0.7+
|
||||
assert results[0].score > 0.65, f"top score too low: {results[0].score}"
|
||||
|
||||
|
||||
@ollama_required
|
||||
def test_paraphrase_matches_semantically(indexed_backend):
|
||||
"""A paraphrase of doc content should still retrieve an on-topic chunk.
|
||||
|
||||
Note: for the "can I run this on a laptop without a gpu?" case the
|
||||
top hit is ``engines.md > llama.cpp`` (not ``hardware.md > Running
|
||||
Without a GPU``) because the llama.cpp section explicitly says
|
||||
"ideal for laptops without a discrete GPU" — a near-literal match
|
||||
for the query. That's fine for grounding: both chunks contain the
|
||||
facts we need (CPU-only, llama.cpp).
|
||||
"""
|
||||
results = indexed_backend.retrieve(
|
||||
"can I run this on a laptop without a gpu?", top_k=3,
|
||||
)
|
||||
assert results, "expected at least one hit"
|
||||
# Top-3 should all be from the topical docs (engines.md or hardware.md)
|
||||
topical = {r.source for r in results[:3]}
|
||||
assert topical <= {"engines.md", "hardware.md"}, (
|
||||
f"off-topic sources in top-3: {topical}"
|
||||
)
|
||||
top_lc = results[0].content.lower()
|
||||
assert "llama.cpp" in top_lc or "cpu" in top_lc
|
||||
|
||||
|
||||
@ollama_required
|
||||
def test_engine_query_finds_engines_doc(indexed_backend):
|
||||
"""Semantic query about inference engines should find engines.md."""
|
||||
results = indexed_backend.retrieve(
|
||||
"which backend is best for high throughput serving?", top_k=3,
|
||||
)
|
||||
assert results
|
||||
assert results[0].source == "engines.md"
|
||||
assert results[0].score > 0.65
|
||||
|
||||
|
||||
@ollama_required
|
||||
def test_off_topic_query_scores_below_threshold(indexed_backend):
|
||||
"""Clearly off-topic queries must fall below the router's threshold.
|
||||
|
||||
This is the property the Twitter bot depends on: when the user asks
|
||||
something unrelated to the docs, retrieval must score below
|
||||
``_SCORE_THRESHOLD`` so the bot chooses the deferral path instead of
|
||||
grounding on nonsense.
|
||||
|
||||
Note: ``nomic-embed-text`` inflates off-topic scores (observed
|
||||
up to ~0.51 on this corpus) — a plain sentence-transformer would
|
||||
give a wider gap, but we're optimizing for the in-process embedder
|
||||
we actually have.
|
||||
"""
|
||||
off_topic_queries = [
|
||||
"how do I bake a chocolate chip cookie",
|
||||
"what is the capital of Mongolia",
|
||||
"recommend me a pop song",
|
||||
"why is the sky blue",
|
||||
]
|
||||
for q in off_topic_queries:
|
||||
hits = indexed_backend.retrieve(q, top_k=1)
|
||||
assert hits, f"expected any hit for {q!r}"
|
||||
assert hits[0].score < _SCORE_THRESHOLD, (
|
||||
f"off-topic query {q!r} scored {hits[0].score:.3f} — above "
|
||||
f"threshold {_SCORE_THRESHOLD}, would trigger a false-positive "
|
||||
f"ground. Tune threshold up or expand corpus."
|
||||
)
|
||||
|
||||
|
||||
@ollama_required
|
||||
def test_score_distribution_vs_threshold(indexed_backend, capsys):
|
||||
"""Document the observed score distribution so the threshold is audit-able.
|
||||
|
||||
We do NOT assert ``rel_min > off_max`` — nomic-embed-text produces
|
||||
overlapping ranges on small narrow corpora. Instead we verify that
|
||||
the chosen threshold cleanly separates the two *medians*, which is
|
||||
the property we actually rely on at the router: **most** relevant
|
||||
queries ground and **all** off-topic queries defer.
|
||||
"""
|
||||
relevant_queries = [
|
||||
"BM25 FAISS Hybrid backends",
|
||||
"can I run this on a laptop without a gpu?",
|
||||
"which backend is best for high throughput serving?",
|
||||
"how do I add a new channel integration",
|
||||
"what happens when i store conflicting facts?",
|
||||
]
|
||||
off_topic_queries = [
|
||||
"how do I bake a chocolate chip cookie",
|
||||
"what is the capital of Mongolia",
|
||||
"recommend me a pop song",
|
||||
"why is the sky blue",
|
||||
]
|
||||
|
||||
def _top1(q: str) -> float:
|
||||
hits = indexed_backend.retrieve(q, top_k=1)
|
||||
return hits[0].score
|
||||
|
||||
rel = sorted(_top1(q) for q in relevant_queries)
|
||||
off = sorted(_top1(q) for q in off_topic_queries)
|
||||
|
||||
rel_median = rel[len(rel) // 2]
|
||||
off_median = off[len(off) // 2]
|
||||
|
||||
print(f"\n relevant scores: {[round(s, 3) for s in rel]}")
|
||||
print(f" off-topic scores: {[round(s, 3) for s in off]}")
|
||||
print(f" relevant median: {rel_median:.3f}")
|
||||
print(f" off-topic median: {off_median:.3f}")
|
||||
print(f" chosen threshold: {_SCORE_THRESHOLD}")
|
||||
|
||||
# Medians must be cleanly separated by the threshold
|
||||
assert rel_median > _SCORE_THRESHOLD, (
|
||||
f"relevant median {rel_median:.3f} <= threshold {_SCORE_THRESHOLD} — "
|
||||
f"too many relevant queries will be deferred."
|
||||
)
|
||||
assert off_median < _SCORE_THRESHOLD, (
|
||||
f"off-topic median {off_median:.3f} >= threshold {_SCORE_THRESHOLD} — "
|
||||
f"too many nonsense queries will ground."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API-level sanity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDenseMemoryAPI:
|
||||
@ollama_required
|
||||
def test_store_and_delete(self):
|
||||
backend = DenseMemory()
|
||||
doc_id = backend.store("the cat sat on the mat", source="a.txt")
|
||||
assert backend.count() == 1
|
||||
hits = backend.retrieve("where is the cat", top_k=1)
|
||||
assert hits and hits[0].metadata["doc_id"] == doc_id
|
||||
assert backend.delete(doc_id)
|
||||
assert backend.count() == 0
|
||||
assert not backend.delete(doc_id)
|
||||
|
||||
@ollama_required
|
||||
def test_empty_retrieve(self):
|
||||
backend = DenseMemory()
|
||||
assert backend.retrieve("anything", top_k=3) == []
|
||||
|
||||
@ollama_required
|
||||
def test_clear(self):
|
||||
backend = DenseMemory()
|
||||
backend.store("foo")
|
||||
backend.store("bar")
|
||||
backend.clear()
|
||||
assert backend.count() == 0
|
||||
assert backend.retrieve("foo", top_k=1) == []
|
||||
@@ -4835,7 +4835,7 @@ channel-twitch = [
|
||||
{ name = "twitchio", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
channel-twitter = [
|
||||
{ name = "tweepy" },
|
||||
{ name = "httpx" },
|
||||
]
|
||||
channel-viber = [
|
||||
{ name = "viberbot" },
|
||||
@@ -5001,6 +5001,7 @@ requires-dist = [
|
||||
{ name = "google-genai", marker = "extra == 'inference-google'", specifier = ">=1.0" },
|
||||
{ name = "gspread", marker = "extra == 'eval-sheets'", specifier = ">=6.0" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "httpx", marker = "extra == 'channel-twitter'", specifier = ">=0.27" },
|
||||
{ name = "line-bot-sdk", marker = "extra == 'channel-line'", specifier = ">=3.0" },
|
||||
{ name = "litellm", marker = "extra == 'inference-litellm'", specifier = ">=1.40" },
|
||||
{ name = "mastodon-py", marker = "extra == 'channel-mastodon'", specifier = ">=1.8" },
|
||||
@@ -5048,7 +5049,6 @@ requires-dist = [
|
||||
{ name = "torch", marker = "extra == 'memory-colbert'", specifier = ">=2.0" },
|
||||
{ name = "torch", marker = "extra == 'orchestrator-training'", specifier = ">=2.0" },
|
||||
{ name = "transformers", marker = "extra == 'orchestrator-training'", specifier = ">=4.40" },
|
||||
{ name = "tweepy", marker = "extra == 'channel-twitter'", specifier = ">=4.14" },
|
||||
{ name = "twilio", marker = "extra == 'channel-twilio'", specifier = ">=9.0" },
|
||||
{ name = "twitchio", marker = "extra == 'channel-twitch'", specifier = ">=2.6" },
|
||||
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.30" },
|
||||
@@ -5060,7 +5060,7 @@ requires-dist = [
|
||||
{ name = "zeus-ml", extras = ["apple"], marker = "extra == 'energy-apple'" },
|
||||
{ name = "zulip", marker = "extra == 'channel-zulip'", specifier = ">=0.9" },
|
||||
]
|
||||
provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "channel-twitter", "browser", "media", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "docs"]
|
||||
provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "docs"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "maturin", specifier = ">=1.12.6" }]
|
||||
@@ -8820,20 +8820,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tweepy"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "oauthlib" },
|
||||
{ name = "requests" },
|
||||
{ name = "requests-oauthlib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/45/a73bb812b1817247d3f79b3b9a4784ab93a081853b697e87428caa8c287b/tweepy-4.16.0.tar.gz", hash = "sha256:1d95cbdc50bf6353a387f881f2584eaf60d14e00dbbdd8872a73de79c66878e3", size = 87646, upload-time = "2025-06-22T01:17:51.34Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/7c/3375cd1fbefcb8ead580fe324b1b6dcdc21aabf51562ee6def7266fcf363/tweepy-4.16.0-py3-none-any.whl", hash = "sha256:48d1a1eb311d2c4b8990abcfa6f9fa2b2ad61be05c723b1a9b4f242656badae2", size = 98843, upload-time = "2025-06-22T01:17:49.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twilio"
|
||||
version = "9.10.4"
|
||||
|
||||
Reference in New Issue
Block a user