Metadata-Version: 2.4
Name: chatbot-core-engine
Version: 1.2.0
Summary: Plug-and-play conversational search engine — an agentic chat engine you point at your own data
Author: trident4
License-Expression: MIT
Project-URL: Homepage, https://github.com/trident4/chatbot_core
Project-URL: Repository, https://github.com/trident4/chatbot_core
Project-URL: Documentation, https://github.com/trident4/chatbot_core/blob/main/README.md
Project-URL: Changelog, https://github.com/trident4/chatbot_core/blob/main/CHANGELOG.md
Keywords: chatbot,agent,llm,rag,semantic-search,pgvector,conversational-ai
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: OS Independent
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: openai>=1.65.0
Requires-Dist: asyncpg>=0.29.0
Requires-Dist: httpx>=0.27.0
Provides-Extra: mcp
Requires-Dist: mcp<2.0.0,>=1.0.0; extra == "mcp"
Requires-Dist: starlette>=0.40.0; extra == "mcp"
Requires-Dist: uvicorn[standard]>=0.32.0; extra == "mcp"
Provides-Extra: mcp-x402
Requires-Dist: mcp<2.0.0,>=1.0.0; extra == "mcp-x402"
Requires-Dist: starlette>=0.40.0; extra == "mcp-x402"
Requires-Dist: uvicorn[standard]>=0.32.0; extra == "mcp-x402"
Requires-Dist: x402>=0.1.0; extra == "mcp-x402"
Provides-Extra: all
Requires-Dist: mcp<2.0.0,>=1.0.0; extra == "all"
Requires-Dist: starlette>=0.40.0; extra == "all"
Requires-Dist: uvicorn[standard]>=0.32.0; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
Dynamic: license-file

# chatbot-core

A plug-and-play **conversational search engine** you install into your own
project. Point it at your data, give it an LLM endpoint, and call `engine.chat()`
— it handles the agentic loop, tool calling, memory, and streaming.

`chatbot_core` is the reusable machinery. It knows nothing about HTTP, your
company, or your workflows. You compose those *around* it, in your own repo.

```bash
pip install chatbot-core-engine
```

> **Install name vs. import name.** The PyPI distribution is **`chatbot-core-engine`** —
> `chatbot-core` was already registered on PyPI by an unrelated project. The import name is
> unchanged: `import chatbot_core`. You install one name and import another, which is normal
> in Python packaging but worth stating once.

Requires Python ≥ 3.11. Async everywhere. Base install pulls only `openai`,
`asyncpg`, `httpx`.

> **▶️ Want to see it running first?** [`examples/quickstart/`](examples/quickstart) is a
> clone-and-run demo — a synthetic 500-product catalog, a pgvector `docker-compose`, and a
> streaming chat UI (with sticky filters, guardrails, and multi-turn scripts). Bring your own
> OpenAI key or a local Ollama:
> ```bash
> cd examples/quickstart && docker compose up -d
> export OPENAI_API_KEY=sk-...
> python seed.py && python -m app          # → http://localhost:8000
> ```

---

## 60-second start

```python
import asyncio
from chatbot_core import ChatEngine, ChatbotConfig
from chatbot_core.adapters import RestApiAdapter

config = ChatbotConfig(
    system_prompt="You are a warm gift-recommendation assistant.",
    # Where your data lives — see "Adapters" below.
    adapter=RestApiAdapter(
        base_url="https://your-backend.example.com",
        search_endpoint="/api/gifts/search",
        detail_endpoint="/api/gifts/{id}",
    ),
    # Any OpenAI-compatible endpoint (OpenAI, OpenRouter, a local gateway…).
    llm_base_url="https://openrouter.ai/api/v1",
    llm_api_key="sk-...",
    llm_model="anthropic/claude-sonnet-4-5",
    result_columns=["id", "name", "price"],
)

engine = ChatEngine(config)

async def main():
    result = await engine.chat(session_id="conv-1", message="a gift for my mum under ₹500")
    print(result["reply"])
    await engine.close()

asyncio.run(main())
```

`chat()` returns `{reply, results, executions, intent, flagged, session_id, usage}`.

---

## The one object you configure: `ChatbotConfig`

Everything about a deployment is one dataclass. The fields you'll reach for most:

| Field | What it does |
|---|---|
| `system_prompt` | Your assistant's instructions. Never mutated — memory is appended with delimiters. |
| `adapter` | Where data comes from (`RestApiAdapter` or `PostgresAdapter`). |
| `llm_base_url` / `llm_api_key` / `llm_model` | Any OpenAI-compatible chat endpoint. |
| `embedding_model` / `embedding_api_key` | **Postgres adapter only** — must match the model your data was embedded with. |
| `result_columns` | Fields returned to the caller. |
| `additional_tools` | Custom tools beyond the two built-ins (see "Tools"). |
| `tools_allowed` | Allow-list of tool names. Empty = all enabled. |
| `extra_filter_properties` | Filter fields exposed to the LLM beyond `min_price`/`max_price`/`category`. |
| `verbatim_turns` | Memory window size (default 10). Also caps agentic-loop iterations. |
| `memory_enabled` / `internal_db_dsn` | Long-term memory (see "Memory"). |
| `guardrail` | Inject your own safety check; defaults to a fast regex. |

It's a plain `@dataclass`, not Pydantic — construct it directly.

---

## Adapters — where your data lives

The engine never touches a database directly. It goes through a `DataAdapter`.

**`RestApiAdapter`** — the engine calls *your* HTTP endpoints; **you** own the
search (and the embedding). This is the recommended path if you want your data
and vectors to stay in your infrastructure.

```python
from chatbot_core.adapters import RestApiAdapter
adapter = RestApiAdapter(
    base_url="https://your-backend.example.com",
    search_endpoint="/api/search",       # POST {query, filters, top_k}
    detail_endpoint="/api/items/{id}",   # GET, {id} substituted
)
```

Your `search_endpoint` receives the tool arguments as the JSON body and must
return `{"results": [...]}` (or a bare array). You embed the query and rank —
the engine never sends you a vector.

**`PostgresAdapter`** — the engine runs pgvector cosine search against a
**read-only** DSN you provide. Requires an `embedding_model` so the engine can
vectorise the query itself.

```python
from chatbot_core.adapters import PostgresAdapter
adapter = PostgresAdapter(
    readonly_dsn="postgresql://readonly@host/db",
    table="products",
    embedding_column="embedding",
    id_column="id",
    allowed_columns=["id", "name", "price", "category"],
    filter_mapping={"max_price": {"column": "price", "operator": "<="}},
)
```

Filtering is forgiving: unknown keys and bad operators are dropped rather than
erroring, column names are whitelist-validated, values are parameterised.

Write your own by subclassing `chatbot_core.adapters.base.DataAdapter`
(`search`, `fetch_by_id`, `execute_tool`).

---

## Tools

Two built-ins are always present: `semantic_search(query, top_k, filters)` and
`fetch_details(id)`. Add your own with `ToolDefinition`:

```python
from chatbot_core.config import ToolDefinition
config.additional_tools = [
    ToolDefinition(
        name="get_reviews",
        description="Get reviews for an item once you have its id.",
        parameters={"type": "object",
                    "properties": {"id": {"type": "integer"}},
                    "required": ["id"]},
        display_type="review_list",
        adapter=my_reviews_adapter,   # optional; falls back to the primary adapter
    ),
]
```

Schemas are auto-fixed to strict mode on build. Max sensible tools per deployment
is small — the LLM's tool selection degrades as the list grows.

### Wiring in an external workflow (the composition pattern)

`chatbot_core` is the conversational layer, **not** a workflow engine. To let a
user drive a long-running, stateful process (book a trip, file a claim), run that
process as its own service (e.g. LangGraph behind FastAPI) and expose it to the
engine as ordinary tools via a `RestApiAdapter`:

```python
ToolDefinition(name="plan_trip",   ...)   # → POST /trip/plan     on your workflow service
ToolDefinition(name="confirm_trip", ...)  # → POST /trip/confirm  (resumes the workflow)
ToolDefinition(name="trip_status",  ...)  # → POST /trip/status
```

The engine treats them like any tool; the durable state, parallelism, and
rollback live in your workflow service. The engine stays the conversational
front-end. See `docs/EPHEMERAL_TOKENS_SPEC.md` and the orchestration notes in the
main repo for the full pattern.

---

## Memory

Two layers:

- **Short-term** — per `session_id`: a sliding window of recent turns kept
  verbatim, older turns LLM-summarised. A new `session_id` is always a clean
  slate.
- **Long-term** — per `(namespace, user_id)`: facts extracted every few messages,
  the only thing that crosses session boundaries. Opt in with
  `memory_enabled=True` and pass a `user_id` to `chat()`.

**Where that state lives is pluggable** via `config.memory` — a `MemoryBackend`.
The engine always does the summarising and fact-extraction; the backend only
decides where the bytes land.

**`PostgresMemory` (default)** — state persists in a Postgres you own. Set
`internal_db_dsn` (or env `INTERNAL_DATABASE_URL`); leave `config.memory` unset.

```python
config.internal_db_dsn = "postgresql://user@host/memorydb"
config.memory_enabled = True
await engine.chat(session_id="conv-1", message="hi", user_id="user-42")
```

**`StatelessMemory`** — the engine stores **nothing**. It still summarises and
extracts facts, but hands the updated state back for **you** to persist wherever
you like. Nothing about your users' conversations touches our database.

```python
from chatbot_core import StatelessMemory

config.memory = StatelessMemory(config)          # no internal_db_dsn needed
engine = ChatEngine(config)

result = await engine.chat(session_id="c1", message="hi", user_id="user-42")
save_to_your_store(result["memory"])             # {recent_messages, summary, user_facts}

# next turn: hand the state back in
prior = load_from_your_store()
result = await engine.chat(session_id="c1", message="more", user_id="user-42", state=prior)
```

The round-trip payload is bounded (the window, a paragraph of summary, ≤20 facts).
This is the OpenAI/Anthropic Messages model — resend the state each turn — except
the engine keeps compacting and remembering for you. The one cost: compaction and
fact extraction run **synchronously** on the turns that need them (there's no
background write to defer to), so those turns are slightly slower.

Write your own backend by subclassing `MemoryBackend` (`load_session`,
`load_user_facts`, `build_context`, `commit_turn`, `clear_session`,
`clear_user_facts`, `close`) — e.g. to store state in Redis or DynamoDB.

For a complete worked example — engine setup, a `/chat` route, the two storage
tables, and the facts-splitting helper — see **`docs/EMBEDDING_IN_YOUR_APP.md`**.

---

## Streaming

```python
async for event in engine.chat_stream(session_id="c1", message="find me a gift"):
    if event["type"] == "text":
        print(event["delta"], end="", flush=True)
    # other events: tool_start, tool_result, error, done
```

---

## Spec-driven deployments: `ClientSpec` + `build_engine`

Don't hand-wire `ChatbotConfig` for a Postgres deployment — describe it as **config** and let the
package build it. A whole deployment becomes data, not code:

```python
from chatbot_core import ClientSpec, build_engine

spec = ClientSpec(
    name="acme",
    dsn="postgresql://…",
    search_relation="products_search",       # your flattened, embedded view/table
    result_columns=["id", "name", "brand", "price", "image"],
    filter_mapping={
        "min_price": {"column": "price", "operator": ">="},
        "max_price": {"column": "price", "operator": "<="},
        "brand":     {"column": "brand", "operator": "ILIKE"},
    },
    enum_filters={"brand": ["Acme", "Globex"]},  # strict schema enums → the model can't invent values
    llm_base_url="https://api.openai.com/v1", llm_model="gpt-4o-mini",
    embedding_model="text-embedding-3-small",
    rerank_url=None,                             # optional cross-encoder rerank
)
engine, adapter = build_engine(spec, api_key="sk-…")   # api key passed in, never stored in the spec
```

`ClientSpec` is pure, JSON-round-trippable config (`spec.save()` / `ClientSpec.load()`). Gateway-
agnostic: set `auth_header` for a custom key header (e.g. `X-Api-Key`), or leave it for
`Authorization: Bearer`.

## Conversation routing: `ConversationRouter`

The agentic loop asks the LLM to decide *whether* to search — a ceremonial round-trip every turn.
`ConversationRouter` decides in **code** (classify-then-route) and hands the engine a pre-run
search, so a plain query costs one LLM call instead of two. It also carries **sticky filters**
across turns (a budget/brand set on one turn refines the next), supports reset/refine, and resolves
references ("tell me about the first one") without re-searching.

```python
from chatbot_core.routing import ConversationRouter, SessionState

router = ConversationRouter(engine, adapter, classifier)   # classifier = your Classifier
result, plan = await router.answer("wireless earbuds under 3000",
                                   session_id="s1", session=SessionState())
# plan["route"] ∈ {"fast", "refine", "reset", "engine", "lookup"}
```

The **`Classifier`** is the one domain-specific piece you provide (a dozen lines matching your
catalog's filter dimensions); the merge semantics, sticky-context lifecycle, and reference lookup
are generic. `chatbot_core.classify` has ready-made building blocks (price parsing, enum matching,
the fresh/refine state machine) to build one on.

### Several things in one message

One embedding vector carries about two intents. Measured on a 1,290-product catalog, the share of
asked-for items actually returned falls off fast as intents are added — and neither a wider `top_k`
nor a looser rerank cutoff recovers it, because the blend of four requests resembles none of them:

| intents | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| before | 96% | 58% | 39% | 38% |
| after | 96% | **100%** | **100%** | **100%** |

So the router splits the message and searches each intent **concurrently** — deterministic code, no
extra LLM call, no added latency. Each sub-query is single-intent again, which also makes the rerank
margin behave (it drops padding *within* one intent instead of culling the others), and each intent
is classified separately so it gets **its own filters**:

```python
split_intents("a mug for my mum and a scarf for my dad")
# ['a mug', 'a scarf']            recipients stripped — they dilute the vector

# "wireless earbuds under 3000 and a laptop sleeve"
#   → earbuds  {max_price: 3000}
#   → sleeve   {category: "Laptop Sleeve"}      ← does NOT constrain the earbuds
```

Results interleave round-robin so a UI showing six cards can't drop the last intent, and each row
is tagged `_intent`. Splitting is **conservative**: an over-eager split gives a *wrong* answer
("salt and pepper shaker" is one product — see `DEFAULT_IDIOMS`, which you extend per catalog),
while a missed split just falls back to previous behaviour. Single-intent messages take the
identical path they always did.

## Safety: guardrails

Defense in depth, all pluggable via `ChatbotConfig`:

- **Fast regex pre-filter** (`Guardrail`, on by default) — blocks blatant harm/injection *before*
  any LLM call, **intent-anchored** so a catalog full of dangerous-sounding product names (bath
  *bomb*, *gun* massager, kitchen *knife*) never false-positives.
- **Category-aware replies** — `guardrail_messages` maps a block reason to a reply, so **self-harm
  gets a compassionate response + resource** (not a cold refusal) — shipped by default.
- **Classifier seam** — inject any `check(text) -> str | None` (sync or async) to add a semantic
  safety model (Llama Guard / ShieldGemma / a moderation endpoint) for obfuscated harm.

```python
config.guardrail = MyClassifierGuardrail()                       # optional Tier-2 semantic check
config.guardrail_messages = {"self_harm": "…care + a resource…"}  # override the safe defaults
```

---

## Optional extras

```bash
pip install "chatbot-core-engine[mcp]"   # MCP server
pip install "chatbot-core-engine[dev]"   # pytest, pytest-asyncio
```

Importing an optional module without its extra raises a clear message telling you
which to install — it never fails at base import.

---

## What this package is *not*

- Not an HTTP server. It's a library. (A reference FastAPI wrapper lives in the
  main repo under `chatbot_service/`, but it is not part of this package.)
- Not a workflow/orchestration engine. Compose one beside it (see "Tools").
- Not multi-tenant on its own. `namespace` isolates memory; everything else is
  one deployment per `ChatEngine`.

---

## API surface

```python
from chatbot_core import ChatEngine, ChatbotConfig
from chatbot_core import ClientSpec, build_engine        # spec-driven deployment
from chatbot_core import MemoryBackend, MemoryState, PostgresMemory, StatelessMemory
from chatbot_core.routing import ConversationRouter, SessionState, SessionStore, Classifier
from chatbot_core import classify                        # price/enum/route building blocks
from chatbot_core.config import ToolDefinition
from chatbot_core.adapters import RestApiAdapter, PostgresAdapter
from chatbot_core.adapters.base import DataAdapter      # subclass for custom sources
from chatbot_core.guardrails import Guardrail           # subclass/inject for custom safety
# optional (needs [mcp]):
from chatbot_core.mcp_server import ChatbotMCPServer
from chatbot_core.payment import ApiKeyPayment, X402Payment, NoPayment

engine = ChatEngine(config, namespace="my-project")     # namespace scopes memory
result  = await engine.chat(session_id, message, user_id=None)
async for ev in engine.chat_stream(session_id, message, user_id=None): ...
await engine.clear_session(session_id)
await engine.clear_user_memory(user_id)
await engine.close()                                     # closes adapter + memory pools
```
