Metadata-Version: 2.4
Name: gyanspark
Version: 0.1.1
Summary: GyanSpark SDK — send student chat exchanges to a knowledge graph and fetch context back
Author-email: GyanSpark <github.yestoideas@gmail.com>
License-Expression: MIT
Keywords: gyanspark,knowledge-graph,education,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.28
Requires-Dist: pydantic>=2.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-mock>=3.12; extra == "dev"
Requires-Dist: requests-mock>=1.12; extra == "dev"
Requires-Dist: python-dotenv>=1.0; extra == "dev"
Requires-Dist: google-genai>=0.1.0; extra == "dev"
Dynamic: license-file

# GyanSpark

[![PyPI version](https://img.shields.io/pypi/v/gyanspark.svg)](https://pypi.org/project/gyanspark/)
[![Python versions](https://img.shields.io/pypi/pyversions/gyanspark.svg)](https://pypi.org/project/gyanspark/)
![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)

Give your AI tutor a memory of each student.

Wrap your existing LLM client with GyanSpark and every chat turn does two extra
things automatically: it learns from the conversation so far, and it grounds
the next reply in what's already known about that student. Your call signature
doesn't change.

```diff
- response = client.chat.completions.create(model="gpt-4o", messages=messages)
+ client = gs.wrap(client, roll_number="A-101")
+ response = client.chat.completions.create(model="gpt-4o", messages=messages)
```

---

## Install

```bash
pip install gyanspark
```

Requires Python 3.9+. Works with Gemini, OpenAI, and Anthropic — you don't
need to have any of them installed for GyanSpark itself to install.

## Setup

Create one client when your process starts, and reuse it for every student.
It holds a connection pool and a background worker pool, so don't build one
per request.

```python
import os
from gyanspark import GyanSpark

gs = GyanSpark(
    api_key=os.environ["GYANSPARK_API_KEY"],
    base_url=os.environ["GYANSPARK_BASE_URL"],
)
```

Both are required. There's no default `base_url` on purpose — a silent
fallback to production when you meant to point at staging is the kind of
mistake that costs a day.

## Wrap your client

`wrap()` is cheap — it allocates nothing but a small object — so call it once
per request, with that request's student.

```python
model = gs.wrap(llm_client, roll_number="A-101")
```

`roll_number` is your own identifier for the student. Use whatever you already
key students by; if GyanSpark hasn't seen it before, the student is created
for you.

---

## Examples

### OpenAI

```python
from openai import OpenAI
from gyanspark import GyanSpark

gs = GyanSpark(api_key="gs-...", base_url="https://...")     # once per process


def handle_message(roll_number: str, messages: list) -> str:
    client = gs.wrap(OpenAI(), roll_number=roll_number)

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
    )
    return response.choices[0].message.content
```

The student's context is added as a `system` message at the front of the
request. If your first message is already a `system` message, your text is
kept and the context is appended to it:

```python
messages = [
    {"role": "system", "content": "You are a patient math tutor."},
    {"role": "user", "content": "how do i factor this?"},
]
# The model receives: "You are a patient math tutor.\n\n<student context>"
```

### Anthropic

```python
import anthropic
from gyanspark import GyanSpark

gs = GyanSpark(api_key="gs-...", base_url="https://...")


def handle_message(roll_number: str, messages: list) -> str:
    client = gs.wrap(anthropic.Anthropic(), roll_number=roll_number)

    response = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=1024,
        system="You are a patient math tutor.",   # kept; context appended
        messages=messages,
    )
    return response.content[0].text
```

Context goes into the top-level `system` parameter. If you pass `system` as a
list of blocks, a text block is appended and your blocks are left as they are.

### Gemini

```python
from google import genai
from gyanspark import GyanSpark

gs = GyanSpark(api_key="gs-...", base_url="https://...")


def handle_message(roll_number: str, contents: list) -> str:
    client = gs.wrap(genai.Client(api_key="..."), roll_number=roll_number)

    response = client.models.generate_content(
        model="gemini-3-flash-preview",
        contents=contents,
    )
    return response.text
```

Context goes into `config.system_instruction`. Pass your own `config` and it's
preserved — every other field is carried over untouched, and the object you
passed is never modified, so it's safe to build one config at startup and
reuse it:

```python
config = genai.types.GenerateContentConfig(
    system_instruction="You are a patient math tutor.",
    temperature=0.4,
)
# reuse `config` for every request — it never accumulates anything
```

### Which call is intercepted

| Provider | Call | Context is added to |
|---|---|---|
| OpenAI | `client.chat.completions.create` | leading `system` message |
| Anthropic | `client.messages.create` | `system=` parameter |
| Gemini | `client.models.generate_content` | `config.system_instruction` |

Everything else on the client — other methods, other attributes, streaming
variants — is passed straight through and behaves exactly as it always did.

---

## What to expect

**Your conversation objects are never modified.** The context is merged into a
copy that exists only for that one request. The `messages` / `contents` /
`config` you passed in come back exactly as you passed them, so whatever you
save to your database is your own conversation and nothing else. Context is
also never added to the conversation itself — only to the system prompt slot —
so there's nothing to strip out later.

**Nothing is cached between calls.** Context is fetched fresh every time and
is never reused, so a student who just had a breakthrough isn't described by
last week's snapshot. This also means the wrapper behaves identically across
processes, workers, and replicas — there's no session state to keep sticky.

**Learning happens in the background.** Recording what the student just did
never blocks your response, and adds no latency to your reply.

**Fetching context is on the critical path.** The wrapped call waits briefly
to retrieve the student's context before calling your LLM, since the reply
depends on it. Budget a short round trip in your request timing.

**It never breaks your chat.** If GyanSpark is slow, unreachable, or has
nothing to say yet, no context is added and your LLM call goes out exactly as
if you hadn't wrapped the client. The one exception is an invalid API key,
which raises — a bad key quietly degrading to "no memory, forever" is a much
worse failure to debug.

**New students start empty.** The first time a `roll_number` is seen there's
nothing to ground a reply in yet, so no context is added. It builds up from
that conversation onward.

**Nothing is recorded until an exchange completes.** GyanSpark learns from the
completed question-and-answer pairs in the history you pass, so the first
message of a conversation records nothing. One consequence worth knowing: the
final exchange of a conversation is never recorded, because nothing follows it
to trigger the recording. If that matters for your use case, record it
explicitly with `record_only` (below) when the session ends.

---

## Options

```python
gs.wrap(llm_client, roll_number, directive=None,
        bypass=False, record_only=False)
```

| Option | Effect |
|---|---|
| `roll_number` | **Required.** Your identifier for the student, and the only identity the SDK needs. Unknown values create a new student. |
| `directive` | Shapes how the returned context is framed for your feature — a quiz generator wants it phrased differently than a chat tutor. |
| `bypass` | Turn GyanSpark off entirely for this client. |
| `record_only` | Record an exchange without calling the LLM. |

### `bypass=True`

You get your client back completely untouched — nothing is recorded, nothing
is retrieved, nothing is injected, and there's no wrapper in the call path at
all. Useful as a kill switch you can drive from config without editing any
call site:

```python
client = gs.wrap(OpenAI(), roll_number="A-101", bypass=settings.MEMORY_DISABLED)
```

Arguments are still validated, so switching it back off can't surprise you
with a new error later.

### `record_only=True`

The call stops being an LLM call. The exchange is recorded and the method
returns `None` — your provider is never contacted, and no context is
retrieved. Use it to submit things the student did outside of chat, like
graded answers, through the same integration you already have:

```python
recorder = gs.wrap(OpenAI(), roll_number="A-101", record_only=True)

recorder.chat.completions.create(
    model="gpt-4o",                     # ignored — nothing is sent to OpenAI
    messages=[
        {"role": "user", "content": "Q: What is the derivative of x²?"},
        {"role": "assistant", "content": "Student answered: 2x, but wrote x³/3 first"},
    ],
)   # -> None
```

Only the most recent user/assistant pair is recorded. Pass a longer trail and
the earlier turns are ignored, with a warning on the `gyanspark` logger. Since
nothing is returned, enable that logger if you want to see problems:

```python
import logging
logging.getLogger("gyanspark").setLevel(logging.WARNING)
```

`bypass` and `record_only` are mutually exclusive — setting both raises
`ValidationError`.

---

## Seeing what's happening

The wrapper is silent by design — which is exactly what you don't want while
integrating. Pass `debug=True` and it narrates every turn:

```python
gs = GyanSpark(api_key=..., base_url=..., debug=True)
```

```
[gyanspark] turn    : 3 conversation turn(s) in history; query='how do i factor it'; 1 completed exchange to record
[gyanspark] write   : queued 8c21-... in the background (2 turn(s), student=A-101) - not waiting on it
[gyanspark] context : empty - student=A-101 is not enrolled yet; the background write is what creates them
[gyanspark] context : skipped - student is not enrolled yet, nothing recorded to fetch.
                      No system prompt will be injected this turn.
[gyanspark] write   : background write 8c21-... finished - recorded
```

and on a later turn:

```
[gyanspark] context : got 412 chars (cutoff_k=3) for student=A-101
[gyanspark] context : <the exact text being added to your system prompt>
[gyanspark] inject  : adding 412 chars to the system prompt
```

That tells you the four things worth knowing: whether the student is known to
the graph yet, whether context came back and what it says, whether it was
injected, and how the background recording finished. Anything skipped says *why*.

Leave it off in production. If you already configure logging, skip the flag
and set the level yourself — same output, your handlers:

```python
logging.getLogger("gyanspark").setLevel(logging.DEBUG)
```

## Errors

Everything importable from `gyanspark`:

| Exception | When |
|---|---|
| `ValidationError` | Bad arguments to `wrap()` — missing `roll_number`, an unsupported client, an async client, or both mode flags set. Raised immediately, before any network call. |
| `ConfigurationError` | Missing `api_key` or `base_url` when constructing `GyanSpark`. |
| `AuthenticationError` | Your API key is invalid or deactivated. |
| `GyanSparkTimeoutError` | A request took too long. |
| `GyanSparkConnectionError` | GyanSpark was unreachable. |
| `GyanSparkAPIError` | Base class for the three above. |

Of these, only `ValidationError`, `ConfigurationError`, and
`AuthenticationError` can reach you in practice — the rest are handled
internally so your LLM call still goes out.

```python
from gyanspark import AuthenticationError, GyanSparkError

try:
    response = client.chat.completions.create(model="gpt-4o", messages=messages)
except AuthenticationError:
    ...   # check your GyanSpark API key
```

Async clients aren't supported by `wrap()` yet and are rejected up front
rather than silently slowing your event loop.

---

## Cleanup

`GyanSpark` holds a connection pool and background workers. Close it on
shutdown, or use it as a context manager:

```python
gs.close()

# or
with GyanSpark(api_key=..., base_url=...) as gs:
    ...
```

## License

MIT — see [LICENSE](LICENSE).
