Metadata-Version: 2.5
Name: oograph
Version: 0.3.0
Summary: Object-oriented agents on LangGraph: typed method contracts, docstring prompts, swappable strategies.
Project-URL: Homepage, https://github.com/vkadel1111/oograph
Author: Vir
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agents,contracts,intent-classification,langchain,langgraph,llm,typed
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.13
Requires-Dist: langchain-core<2.0,>=1.0
Requires-Dist: langgraph<2.0,>=1.0
Requires-Dist: pydantic>=2.7
Provides-Extra: anthropic
Requires-Dist: langchain-anthropic<2.0,>=1.0; extra == 'anthropic'
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=1.2; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.16; extra == 'dev'
Provides-Extra: intent
Requires-Dist: sentence-transformers>=3.0; extra == 'intent'
Provides-Extra: intent-train
Requires-Dist: sentence-transformers[train]>=3.0; extra == 'intent-train'
Description-Content-Type: text/markdown

# oograph

**Object-oriented agents on LangGraph.** Fields are state, methods are
capabilities, docstrings are prompts, type annotations are contracts —
and LangGraph supplies durable execution, checkpointing, streaming, and
the tool loop underneath.

Patterns adapted from NVIDIA's [NOOA / labs-OO-Agents](https://github.com/NVIDIA-NeMo/labs-OO-Agents)
research framework, re-expressed for production environments: the
CodeAct execution model (LLM-written Python) is deliberately replaced
with LangGraph's tool loop, so there is no arbitrary code execution to
get past a security review.

```python
from pydantic import BaseModel
from oograph import Agent, generation, PredictStrategy, ReActStrategy

class FulfillmentResult(BaseModel):
    can_fulfill: bool
    total_cost: float
    unavailable_items: list[str]

class InventoryAgent(Agent, llm=my_llm):
    """You check inventory for {self.store_name}. Never invent stock
    numbers — always call the tools."""

    tools_allowlist = ("get_stock", "get_price")   # pinned tool surface

    def __init__(self, store_name="Main DC", **kw):
        super().__init__(**kw)
        self.store_name = store_name

    def get_stock(self, item: str) -> int:
        """Current stock for an item."""
        ...

    def get_price(self, item: str) -> float:
        """Unit price for an item."""
        ...

    @generation(strategy=ReActStrategy(max_iterations=8))
    async def can_fulfill_order(self, items: list[str], budget: float) -> FulfillmentResult:
        """Check whether the order can be fulfilled within budget."""
        ...

    @generation(strategy=PredictStrategy())       # cheap single-shot path
    async def classify_urgency(self, message: str) -> str:
        """Classify the urgency of this customer message."""
        ...
```

```python
result = await agent.can_fulfill_order(["apple", "orange"], budget=5.0)
# result is a validated FulfillmentResult — guaranteed, or ContractViolation
```

## The seven patterns

| Pattern | Interface | What it gives you |
|---|---|---|
| Docstring-is-the-prompt | `@generation` + `MethodSpec` | Rename the method, change the behavior; `{self.attr}` templating with **no eval** (`SafeFormatter`) |
| Typed contract boundary | `build_contract_graph` | Pydantic validate + error-feedback retry as a real LangGraph — retries checkpoint and stream |
| Swappable strategies | `PredictStrategy` / `ReActStrategy` | Execution detail, not interface: cost-tier per method without touching callers |
| Model-writable context | `agent.context` + auto tools | The agent pins/removes its own system-prompt sections mid-run |
| Progressive disclosure | `agent.discoverable` → `doc` tool | Prompt stays bounded as the domain grows |
| Graph interop | `agent.as_node` / `agent.as_tool` | Drop into an existing StateGraph or hand agents to agents as tools |
| LLM cascade | class → `@generation(llm=…)` → instance | Haiku on classification, Sonnet on the tool loop, same class |


## Integrating with an existing graph

You don't have to let oograph own the graph. Generation methods are
async callables with guaranteed typed returns, so they drop into a
graph you already have:

```python
class Pipeline(TypedDict):
    sku: str
    quantity: int
    verdict: Verdict        # a validated BaseModel crosses the boundary

g = StateGraph(Pipeline)
g.add_node("review", agent.as_node(
    "review",
    args={"item": "sku", "qty": "quantity"},   # state key or state -> value callable
    output_key="verdict",
))
# ...your edges, your checkpointer, your interrupts
```

Or hand an agent to another agent as a tool:

```python
supervisor_llm.bind_tools([agent.as_tool("review")])
```

The **subagent seam** is declared on the method itself:

```python
@generation(strategy=ReActStrategy(), history="emit")   # isolated | emit | full
async def review(self, item: str, qty: int) -> Verdict: ...
```

`"isolated"` (default) is pure subagent semantics — fresh context in,
typed return out. `"emit"` also appends the serialized result to the
parent `messages` channel as an AIMessage attributed to the method
name. `"full"` additionally reads the parent transcript into the
method's context. Internal tool-loop chatter never crosses the seam
in any mode.

The write surface is **closed**: an emitting node updates exactly
`output_key` plus the `messages` channel — no other parent state field
can be touched. Pass `state_schema=` to validate the wiring when the
node is constructed instead of when it fires:

```python
agent.as_node("review", args={...}, output_key="verdict",
              state_schema=Pipeline)   # SeamConfigError on bad wiring
```

This catches a missing `output_key`, an output type that contradicts
the channel's declaration, an `args` source that names a channel the
schema lacks, a missing `messages` channel under emit/full, and — the
one silent hazard runtime can't catch — a reducer-less `messages`
channel that emission would replace instead of append to. An `args`
key that names a parameter the method doesn't have is rejected even
without a schema. Parameters with signature defaults may be absent
from the state (and, in `as_tool`, become optional fields).

`as_node` maps method params from your state schema; `as_tool` derives
the args schema from the method signature and serializes BaseModel
results to JSON. Contracts run *inside* the method either way, so
downstream nodes can trust the value's type.

## Intent classification (`oograph.intent`)

An optional subpackage for routing requests that carry several intents
in long prose — a salesperson asking about an order, their commission
plan, and a product in one message:

```python
from oograph.intent import (
    SALES_INTENTS, EmbeddingScorer, IntentAgent, IntentClassifier, SentenceTransformerEncoder,
)

clf = IntentClassifier(
    SALES_INTENTS,                                       # order_status | compensation_rules | product_search
    EmbeddingScorer(SentenceTransformerEncoder()),       # all-MiniLM-L6-v2; pip install "oograph[intent]"
    fallback=IntentAgent(SALES_INTENTS, llm=my_llm),     # optional LLM tier
    escalate_below=0.7,                                  # weak best match -> ask the LLM
)
clf.calibrate()                                          # threshold from the catalog itself (leave-one-out)
result = await clf.aclassify(
    "The Garcia order hasn't shown up, can you get a tracking number? Separately, "
    "do multi-year contracts pay the same commission rate?"
)
result.intents   # ('order_status', 'compensation_rules')
result.routes    # ('order_api', 'document_search')
result.matches   # one (span, intent, score, route) per matched clause
```

**How it works.** Long prose is split into clauses; every clause is
scored against every intent; anything over the threshold becomes a
match carrying its span and route. That is what makes multi-intent
detection work with single-label-style scorers, and it hands each
handler the exact text it should act on. Scoring backends sit behind a
`Scorer` protocol: `EmbeddingScorer` (prototype similarity over any
`Encoder`; add an intent by adding examples) and `ZeroShotScorer` (NLI,
no examples at all). `IntentAgent` is the LLM tier — an ordinary oograph
agent whose contract is the typed intent report, so a hallucinated
intent name is dropped, never routed. `IntentClassifier.evaluate`
reports per-intent precision/recall/F1 for threshold calibration.

**Why not IntentBERT.** IntentBERT (Zhang et al., 2021) is a BERT-base
checkpoint pre-trained on intent datasets for few-shot *single-label*
intent detection. It is a research artifact: no maintained checkpoint on
the Hugging Face Hub, English only, and it still needs the same
prototype/kNN head and segmentation this module provides. Modern
sentence-embedding models trained contrastively on far larger data do
that job at a third of the size, with maintenance. The encoder is
pluggable, so an IntentBERT checkpoint can be evaluated with
`SentenceTransformerEncoder(model_name=...)` if you want to check.

**Measured on a held-out set** (36 requests, none overlapping the seed
examples: 24 single-intent, 8 multi-intent long prose, 4 out of scope;
CPU in a container; `co_label_margin=0.05`; threshold swept per backend,
with the threshold `calibrate()` picks unaided shown alongside):

| Backend | Exact-set acc | Macro-F1 | OOS rejected | p50 | p95 | `calibrate()` |
|---|---|---|---|---|---|---|
| `all-MiniLM-L6-v2` (22M) | **0.83** | **0.92** | 4/4 | **14 ms** | 21 ms | 0.66 → 0.81 |
| `bge-small-en-v1.5` (33M) | 0.72 | 0.82 | 4/4 | 24 ms | 32 ms | 0.81 → 0.75 |
| `bge-base-en-v1.5` (109M) | 0.81 | 0.90 | 4/4 | 71 ms | 95 ms | 0.79 → 0.81 |
| `deberta-v3-base` zero-shot NLI (184M) | 0.25 | 0.31 | 4/4 | 2.5 s | 5.4 s | n/a |

Exact-set accuracy counts a request right only when the whole set of
intents is right, so multi-intent prose is scored strictly. The lexical
`HashingEncoder` baseline sits around 0.3. The NLI backend is precise
(1.00 on every intent) but recall-starved with the shipped descriptions
as labels, and two orders of magnitude slower. The remaining MiniLM
errors are near-misses on a single clause (a second intent co-firing,
or one under-specified product question), which is exactly the tail
the LLM tier is for.

Recommendation: `all-MiniLM-L6-v2` + `EmbeddingScorer`, `calibrate()`
after any change to the catalog or encoder, and `escalate_below` set so
the weak tail goes to `IntentAgent`. Move to `bge-base` only if the
extra recall on product questions is worth 5x the latency. Use
`ZeroShotScorer` only before any example data exists, and grow the
catalog from real requests — `evaluate()` on a labeled sample is the
signal for when to re-tune.

## Training cycle

`oograph.intent.tune` turns a JSONL file of labeled requests into a
versioned classifier artifact. One record per line; `text` and
`intents` are required, the rest optional:

```jsonl
{"text": "Where is PO 5512? Also, does the SPIFF cover services?", "intents": ["order_status", "compensation_rules"]}
{"text": "Can you book me a flight to Chicago?", "intents": []}
{"text": "Is there an accelerator past 120% of target?", "intents": ["compensation_rules"], "split": "test"}
{"text": "Track order 3390.", "intents": ["order_status"], "spans": [{"intent": "order_status", "span": "Track order 3390."}], "reviewed": false, "source": "escalation"}
```

- An empty `intents` list is an out-of-scope example — the classifier
  needs those to learn what to reject.
- `spans` names the clause carrying each intent. Multi-intent records
  without spans are segmented and weakly labeled (each segment gets the
  best-scoring of the record's own intents; ambiguous segments are
  dropped and counted).
- `split` pins a record to `train` or `test`; otherwise a hash of the
  text decides, so the split is stable as the file grows.
- `reviewed: false` keeps a record out of training until a human
  confirms it. `EscalationLog` writes every LLM-escalated request in
  exactly this shape, so the ambiguous tail becomes the training queue.

```bash
pip install -e ".[intent-train]"
python -m oograph.intent.tune requests.jsonl --baseline-only        # validate + measure
python -m oograph.intent.tune requests.jsonl --out models/intent-v2  # fine-tune, gate, save
```

The cycle: validate the file against the catalog → split → **baseline**
(catalog examples plus training records as prototypes over stock
MiniLM, calibrated, scored on the test split) → **fine-tune** MiniLM
with batch-hard triplet loss on single-label units (out-of-scope text is
a label too, so the space learns to hold it apart) → re-evaluate with
the same prototype scorer over the tuned encoder → **gate**: the
artifact is written only if the tuned model beats the baseline on
macro-F1, then exact-set accuracy. Exit status 1 means "not promoted".

An artifact is `model/` (the encoder), `catalog.json` (the catalog with
training units folded into examples) and `manifest.json` (threshold,
both evaluations, dataset digest, base model). Load it with
`load_artifact(path)` and you get the exact `IntentClassifier` the gate
measured. Retrain whenever the catalog changes or the escalation rate
climbs; `--force` saves a losing model when you need the artifact anyway.

On the 66-record synthetic set in this repo the gate refuses the
fine-tune (0.84 vs 0.95 exact-set): that is expected, and the point.
Fine-tuning starts to pay at roughly 100 real requests per intent.

## Design notes

- **The contract guards every exit.** `ReActStrategy` funnels its tool-loop
  transcript through the contract graph as a subgraph, so malformed output
  cannot escape a method no matter how messy the loop was. When
  `max_iterations` tool rounds are spent, the loop hands the transcript to
  the contract graph for a final tool-free answer — the only failure mode
  is `ContractViolation`, never a bare recursion error.
- **Contracts are checked at decoration time.** A `@generation` method must
  be `async def` with every parameter annotated; the return annotation is
  rendered for the model as JSON Schema, so `list[Verdict]` or
  `Verdict | None` shows the nested fields, not an opaque type name.
- **`tools_allowlist`** pins the exact tool surface for regulated
  environments; omit it and every public method is exposed (NOOA's
  convention).
- **Checkpointing is free.** Pass any LangGraph `BaseCheckpointSaver`;
  each generation method runs on its own thread id
  (`{agent_thread}:{method}`), so HITL interrupts and resume-after-crash
  work out of the box.

## Install

```bash
pip install -e ".[dev]"
pytest
```

To run the Anthropic example, also install the extra:

```bash
pip install -e ".[anthropic]"
ANTHROPIC_API_KEY=... python examples/inventory.py
```

Requires Python ≥ 3.13, `langgraph>=1.0`, `langchain-core>=1.0`, `pydantic>=2.7`.

## License

Apache-2.0.
