Skip to main content
Glama
ABHIJEET-MUNESHWAR

WisdomAgent

WisdomAgent

Grounded agentic Q&A over customer feedback. An analyst asks a question in English; a bounded agent plans, calls typed tools over a feedback corpus, and returns an answer in which every claim cites specific feedback record IDs — or refuses to answer at all.

CI Coverage Python Ruff License Stars Issues Last commit


Table of contents


Related MCP server: tero-mcp-lite

Why this exists

A support inbox, an app store and a CSAT survey add up to tens of thousands of short, messy documents. The questions people want to ask of that pile are exactly the questions an LLM is good at sounding right about:

Why do customers abandon the checkout flow when a discount code is applied?

The failure mode is not that the model says nothing. It is that it says something plausible, specific and unsourced — and it gets quoted in a roadmap review. Once one number in a deck turns out to be invented, every number in every deck is suspect, and the tool is finished.

WisdomAgent's premise is that an unverifiable answer is worse than no answer. So the system is built around a verifier rather than a generator:

  1. Retrieval finds candidate feedback records.

  2. A bounded agent drafts an answer from those records only.

  3. A grounding checker re-derives, claim by claim, whether the draft is actually supported by the evidence — and by which record.

  4. Anything unsupported is pruned. If pruning leaves nothing, the system refuses and says why.

A refusal is a first-class success. It is measured (wa_refusals_total), alerted on in both directions (too many refusals and too few), and scored in the offline eval as refusalCorrectness — because a system can trivially drive its hallucination rate to zero by refusing everything.


What it does

The same domain, surfaced three ways, with no second-class implementation behind any of them:

Surface

Endpoint

For

GraphQL

POST /graphql

Analysts and product UIs. One round trip returns the answer, its grounding report, the fused ranking with component scores, the source documents and the agent trace.

MCP

POST /mcp

Agent clients. JSON-RPC 2.0 initialize / tools/list / tools/call over the same four typed tools the internal agent uses.

Monitors

worker process

Saved questions evaluated on a cadence, emitting deduplicated digest events when a condition breaches.


Architecture

Hexagonal (ports and adapters). The dependency rule is enforced by import discipline and asserted by a test: wisdomagent/domain/ imports nothing from FastAPI, asyncpg, aiokafka, redis or httpx. All retrieval mathematics, budget accounting, grounding logic and monitor state machines live there and are testable with no infrastructure at all.

flowchart TB
    subgraph clients["Clients"]
        UI["Analyst / product UI"]
        AGENTC["MCP agent client"]
        PROM["Prometheus"]
    end

    subgraph driving["Driving adapters"]
        GQL["GraphQL schema<br/><code>api/schema.py</code>"]
        MCP["MCP server<br/><code>adapters/mcp/server.py</code>"]
        HTTP["FastAPI app + middleware<br/><code>api/app.py</code>"]
        WORK["Monitor worker<br/><code>worker.py</code>"]
    end

    subgraph app["Application layer — orchestration only"]
        CMD["FeedbackCommandService<br/>(writes)"]
        QRY["FeedbackQueryService<br/>(reads)"]
        SAGA["AnswerSaga"]
        MED["Mediator + middleware<br/><code>app/messages.py</code>"]
        PORTS["Ports (Protocols)<br/><code>app/ports.py</code>"]
    end

    subgraph domain["Domain — pure, no I/O"]
        MODEL["Model &amp; value objects"]
        LEX["BM25<br/><code>domain/lexical.py</code>"]
        VEC["Embedder + ANN<br/><code>domain/vector.py</code>"]
        FUSE["RRF / weighted fusion"]
        RANK["Feature reranker"]
        GRND["Grounding checker"]
        BUD["Budget"]
        PLAN["Planner"]
        MON["Monitor state machine"]
        EVAL["Eval harness"]
        PART["Partition / shard maths"]
    end

    subgraph driven["Driven adapters"]
        MEM["In-memory stores"]
        PG["Postgres<br/>(partitioned + sharded)"]
        KAF["Kafka publisher"]
        RED["Redis cache"]
        LLM["LLM client<br/>(planner / drafter / judge)"]
        TOOLS["Tool catalogue"]
    end

    subgraph obs["Observability"]
        MET["Prometheus registry"]
        LOG["Structured JSON logs"]
        TRC["OpenTelemetry"]
    end

    UI --> HTTP --> GQL --> MED --> CMD & QRY
    AGENTC --> HTTP --> MCP --> TOOLS
    WORK --> CMD
    QRY --> SAGA
    SAGA --> PLAN & LEX & VEC & FUSE & RANK & GRND & BUD
    CMD --> MODEL & MON & EVAL & PART
    CMD & QRY & SAGA -.->|depend only on| PORTS
    PORTS -.->|implemented by| MEM & PG & KAF & RED & LLM
    TOOLS --> QRY
    HTTP & SAGA & CMD --> MET & LOG & TRC
    PROM --> MET

    classDef pure fill:#e8f5e9,stroke:#2e7d32
    classDef adapter fill:#e3f2fd,stroke:#1565c0
    classDef appc fill:#fff3e0,stroke:#ef6c00
    class MODEL,LEX,VEC,FUSE,RANK,GRND,BUD,PLAN,MON,EVAL,PART pure
    class GQL,MCP,HTTP,WORK,MEM,PG,KAF,RED,LLM,TOOLS adapter
    class CMD,QRY,SAGA,MED,PORTS appc

Component reference

Every box in the diagram, what it owns, and why it is separate from its neighbours.

Component

Module

Responsibility

Why it is its own box

FastAPI app + middleware

wisdomagent/api/app.py

Correlation ids, rate limiting, RED metrics, probes, error→status mapping

Middleware order is a correctness concern: correlation is outermost so every log line and error body carries one; rate limiting sits inside it so a flood is rejected before it can allocate an index scan

GraphQL schema

wisdomagent/api/schema.py

Types, resolvers, argument validation

GraphQL, not REST, because one ask returns six correlated projections (answer, grounding, hits, documents, trace, rerank) and a REST client would either over-fetch all of them or make six calls

Mappers

wisdomagent/api/mappers.py

Domain → GraphQL type conversion

Keeps strawberry decorators out of the domain and stops a schema rename from becoming a domain change

MCP server

wisdomagent/adapters/mcp/server.py

JSON-RPC 2.0 envelope, tool schemas, output truncation

Transport concern only. It reuses the exact ToolCatalogue the internal agent calls, so the two surfaces cannot drift

Monitor worker

wisdomagent/worker.py

Evaluate saved monitors on a cadence, forever

Separate process: a sweep is bursty CPU that would otherwise add its latency to whatever request shared the event loop, and the two scale on different signals

FeedbackCommandService

wisdomagent/app/service.py

Index, delete, save monitors, run evals; publishes events

CQRS write side. Commands publish events and never return projections

FeedbackQueryService

wisdomagent/app/service.py

Search, ask, traces, digests, health

CQRS read side. Queries never mutate the corpus

AnswerSaga

wisdomagent/app/service.py

plan → retrieve → rerank → draft → verify → repair → finalize

The interesting failure here is not a crash but a draft that cannot be grounded; the saga's compensation is a downgrade, not a rollback

Mediator

wisdomagent/app/messages.py

Message dispatch with pluggable middleware

Logging, timing and correlation apply to every use case without each one repeating them

Ports

wisdomagent/app/ports.py

Protocol definitions for every outbound dependency: DocumentRepository, SearchIndex, TraceStore, MonitorStore, DigestStore, EvalStore, EventPublisher, Cache, Drafter

Runtime-checkable structural typing: an adapter is compatible because of its shape, not because it inherited from us. A test fails any port that grows past 8 methods, which is what forced DigestStore out of MonitorStore

Model & value objects

wisdomagent/domain/model.py

DocId, TenantId, RunId, Document, Answer, Claim, AgentTrace

Frozen and validating. An invalid Source cannot be constructed, so no downstream code needs to re-check it

BM25

wisdomagent/domain/lexical.py

Inverted index, IDF, length normalisation

Written from scratch: an exact-term match on "SAVE20" is what a lexical arm exists for, and it must be inspectable to be arguable

Embedder + ANN

wisdomagent/domain/vector.py

Deterministic hashed embeddings, flat and IVF-lite search

Deterministic by design — the offline path must produce the same ranking on every machine, or the eval harness is measuring the weather

Fusion

wisdomagent/domain/fusion.py

Reciprocal rank fusion and weighted-score fusion

RRF needs no score calibration between two arms whose scores are on incomparable scales; weighted fusion is available when they are calibrated

Reranker

wisdomagent/domain/rerank.py

Explainable feature scoring (relevance, recency, source, coverage)

Every feature contribution is returned, so "why is this document first" has an answer that is not "the model felt like it"

Grounding checker

wisdomagent/domain/grounding.py

Per-claim support, best span, citation precision, coverage

The component the entire product rests on. Deliberately independent of the drafter, so a drafter bug cannot mark its own homework

Budget

wisdomagent/domain/budget.py

Step, per-tool, token and wall-clock ceilings

What makes "agentic" safe to run in production: an unbounded loop is an unbounded bill and an unbounded latency

Planner

wisdomagent/domain/planner.py

Intent classification → tool plan

Deterministic heuristic by default; the LLM planner is an optional upgrade that falls back to this one

Monitor state machine

wisdomagent/domain/monitors.py

ok → pending → firing → resolved, breaches, cooldown, fingerprints

Anti-flap logic is pure and unit-tested; the scheduler around it is trivial

Eval harness

wisdomagent/domain/evaluation.py

Case grading, aggregation, score gate

The regression gate on the answer path. Scores refusals for correctness, not just for absence

Partition maths

wisdomagent/domain/partition.py

Tenant→shard hashing, time→partition mapping, balance

The same function decides the shard in Python and in SQL, so the app and the schema can never disagree

In-memory stores

wisdomagent/adapters/memory.py

Repository, index, traces, monitors, evals, publisher, cache

Not test doubles — the default production adapters for a single-node deployment, which is why they are held to the same coverage bar

Postgres

wisdomagent/adapters/postgres.py

Partitioned, sharded document storage

Optional. Blank DSN ⇒ the in-memory repository, and every test still passes

Kafka publisher

wisdomagent/adapters/messaging.py

Domain events to a topic

Optional. Blank brokers ⇒ in-process publication

Redis cache

wisdomagent/adapters/redisx.py

Answer/aggregate caching with TTL

Optional. Blank URL ⇒ no cache; a cache is never load-bearing for correctness

LLM client

wisdomagent/adapters/agent/client.py

Chat completions behind the full resilience stack

Optional. Blank base URL ⇒ the deterministic offline path, everywhere

Tool catalogue

wisdomagent/adapters/agent/tools.py

The four typed tools and their schemas

Shared by the internal agent and the MCP surface; returns a failed ToolResult rather than raising, so one bad call degrades a step and not a run

Metrics

wisdomagent/observability/metrics.py

33 wa_* collectors on a private registry

Private registry, not the global default: two containers in one process must not fight over collector names

Logging

wisdomagent/observability/logging.py

Structured JSON, correlation propagation via contextvars

Correlation survives await boundaries without being threaded through every signature

Design patterns in use

Named, and each with the specific problem it solves here.

Pattern

Where

Problem it solves

Hexagonal / Ports & Adapters

app/ports.pyadapters/*

The domain is testable without Postgres, Kafka, Redis or an LLM — which is why the suite runs in ~3 s

CQRS

FeedbackCommandService / FeedbackQueryService

Reads outnumber writes by orders of magnitude and want different caching and consistency

Saga (with compensation)

AnswerSaga

A multi-step answer where the interesting failure is semantic; compensation is pruning and refusal, not rollback

Strategy

fusion.py (RRF ↔ weighted), planner (heuristic ↔ LLM), drafter (extractive ↔ LLM), index (flat ↔ IVF)

Swap an algorithm by configuration without touching the caller

Adapter

postgres.py, redisx.py, messaging.py, client.py

Third-party clients never leak past the port boundary

Repository

DocumentRepository port

Persistence is an implementation detail of storage, not a shape the domain has to know

Mediator

app/messages.py

Cross-cutting middleware (logging, timing, correlation) applied once instead of per use case

Decorator / Middleware

logging_middleware, HTTP middleware, ResiliencePolicy

Behaviour composed around a call without editing the call

Circuit Breaker + Bulkhead + Retry + Timeout + Rate Limiter

resilience/resilience.py

Every I/O boundary; see Resilience

Factory

build_container(), _build_repository(), _build_drafter()

One place decides which adapters a deployment gets, based on configuration

Builder

Settings / RetrievalSettings / AgentSettings composition

Frozen configuration assembled from the environment, validated once at startup

Value Object

DocId, TenantId, RunId, Query, Source

Illegal states unrepresentable; validation happens at construction, once

State machine

domain/monitors.py

Explicit ok/pending/firing/resolved transitions instead of implicit boolean flags

Template Method

ResiliencePolicy.execute

Fixed order — bulkhead, breaker, timeout, retry — with the operation as the varying part

Outbox

event_outbox table + EventPublisher

An event and the state change that caused it commit together or not at all

Null Object

in-memory cache/publisher when Redis/Kafka are unconfigured

Absence of infrastructure is not a branch in business logic

Specification

FilterInput → domain filters

Query predicates composed and pushed into retrieval rather than applied to the top-k afterwards


Core flows

1. Ingest

sequenceDiagram
    autonumber
    participant C as Client
    participant G as GraphQL
    participant CM as CommandService
    participant R as Repository
    participant IX as HybridSearchIndex
    participant EV as EventPublisher

    C->>G: mutation indexDocuments(documents, batchId)
    G->>CM: index(tenant, documents, batchId)
    CM->>CM: validate each DocumentInput → Document
    Note over CM: Invalid source / blank text are rejected<br/>per document, not per batch
    CM->>R: save_many(tenant, documents)
    R-->>CM: newly saved (existing ids skipped)
    CM->>IX: add(documents)
    IX->>IX: tokenize → BM25 postings
    IX->>IX: embed → ANN insert
    CM->>EV: documents.indexed
    CM-->>G: IndexResult{saved, indexed, corpusSize}

Idempotent per document id, so an at-least-once delivery pipeline can replay a batch safely. saved and indexed are returned separately precisely so a replay is visible as 0 / 0 rather than silently swallowed.

2. Ask — the answer saga

sequenceDiagram
    autonumber
    participant C as Client
    participant Q as QueryService
    participant S as AnswerSaga
    participant P as Planner
    participant IX as HybridSearchIndex
    participant RK as Reranker
    participant D as Drafter
    participant V as GroundingChecker
    participant T as TraceStore

    C->>Q: ask(question, topK, filters)
    Q->>S: run(query, budget)
    S->>P: classify intent → plan
    S->>IX: search(query, limit = topK × multiplier)
    IX-->>S: candidate hits (fused)
    S->>RK: rerank(hits, documents, now)
    RK-->>S: ordered documents + feature breakdown
    alt no documents
        S-->>Q: REFUSE "no indexed feedback matched"
    else evidence relevance < floor
        Note over S: Ranking has no notion of "nothing here is relevant".<br/>Without this gate, an extractive draft would quote the<br/>nearest documents and the verifier would pass it —<br/>a quote is trivially supported by its own source.
        S-->>Q: REFUSE "does not discuss this topic"
    else
        S->>D: draft(query, documents)
        D-->>S: Answer{claims[], citations[]}
        S->>V: verify(claims, evidence)
        V-->>S: GroundingReport{per-claim support}
        alt accepted
            S->>T: save trace
            S-->>Q: AskResult
        else repairable
            S->>D: repair — prune to supported claims
            S->>V: re-verify
            S-->>Q: AskResult (downgraded) or REFUSE
        end
    end

Every step is appended to the trace before it can fail, so a partial run is still a readable audit record — and every appended step is also counted into wa_saga_steps_total, so the Grafana step-mix panel and the audit trail are the same story at two resolutions (there is a test asserting exactly that).

The budget is checked at every step: steps used, tool calls per tool, tokens and wall clock. Exceeding any of them ends the run with a typed error rather than an unbounded loop.

3. Hybrid retrieval, fusion and reranking

flowchart LR
    Q["Query text"] --> TOK["Tokenize<br/>fold, strip, n-grams"]
    TOK --> BM["BM25<br/>inverted index"]
    Q --> EMB["Hashed embedder<br/>deterministic, d=128"]
    EMB --> ANN["ANN index<br/>flat / IVF-lite"]
    BM --> L["Lexical ranking"]
    ANN --> V["Vector ranking"]
    L --> F{"Fusion<br/>strategy"}
    V --> F
    F -->|rrf| RRF["Reciprocal rank fusion<br/>1/(k + rank)"]
    F -->|weighted| W["Weighted score<br/>λ·lex + (1−λ)·vec"]
    RRF --> CAND["Candidates<br/>topK × multiplier"]
    W --> CAND
    CAND --> RR["Feature reranker"]
    RR --> FEAT["relevance · recency<br/>source weight · coverage"]
    FEAT --> TOPK["Final top-k<br/>+ per-feature explanation"]

Both component scores survive into the API response. That is what makes a ranking argument settleable: a result with a high vector score and a zero lexical score is a semantic match with no term overlap, and that is usually where a surprising citation comes from.

RRF is the default because the two arms produce scores on scales that were never calibrated against each other — BM25 is unbounded and corpus-dependent, cosine similarity is in [−1, 1]. Rank fusion sidesteps calibration entirely. Weighted fusion is there for deployments that have done the calibration work.

4. Grounding verification and refusal

flowchart TD
    A["Draft answer"] --> B["Split into claims"]
    B --> C{"For each claim"}
    C --> D["Find the best supporting span<br/>across the retrieved evidence"]
    D --> E{"support ≥ min_support?"}
    E -->|yes| F["Supported — record docIds + span"]
    E -->|no| G["Unsupported — record reason"]
    F & G --> H["GroundingReport<br/>groundedness · citationPrecision · coverage"]
    H --> I{"groundedness ≥ min_groundedness<br/>and every claim supported?"}
    I -->|yes| J["ACCEPT — return with citations"]
    I -->|no| K{"repairs remaining?"}
    K -->|yes| L["Prune unsupported claims<br/>re-verify the remainder"]
    L --> I
    K -->|no| M["REFUSE<br/>with the failure reason"]
    J --> N["wa_groundedness_score<br/>wa_citation_precision"]
    M --> O["wa_refusals_total{reason}<br/>wa_unsupported_claims_total"]

Three distinct gates, each catching a different lie:

  • Evidence relevance floor — is the retrieved evidence about the question at all? Catches "confidently answering from the nearest unrelated documents".

  • Per-claim support — is this sentence traceable to that record? Catches a fluent summary that quietly generalises beyond its sources.

  • Citation precision — does every cited id actually support the claim it is attached to? Catches citation padding, where the answer is right but the sourcing is decorative.

5. MCP tools/call

sequenceDiagram
    autonumber
    participant M as MCP client
    participant H as POST /mcp
    participant S as McpServer
    participant TC as ToolCatalogue
    participant Q as QueryService

    M->>H: {"jsonrpc":"2.0","id":1,"method":"initialize"}
    H->>S: handle(request)
    S-->>M: serverInfo + capabilities
    M->>H: tools/list
    S-->>M: 4 tools with JSON Schema
    M->>H: tools/call {name, arguments}
    S->>S: strip reserved tenant args (defence in depth)
    S->>TC: execute(name, tenant, args) with timeout
    TC->>Q: typed query
    alt tool succeeded
        TC-->>S: ToolResult{ok, data}
        S->>S: truncate to WA_MCP_MAX_OUTPUT_CHARS
        S-->>M: {"result":{"content":[…],"isError":false}}
    else tool failed (bad argument, unknown tool)
        TC-->>S: ToolResult{ok:false, error}
        S-->>M: {"result":{"content":[reason],"isError":true}}
    else malformed request (no tool name)
        S-->>M: {"error":{"code":-32602,…}}
    end

The distinction in the last two branches is deliberate. A failed tool comes back as a normal result with isError: true, because the model can read the reason and re-plan. A malformed request goes into the JSON-RPC error channel, because there is nothing to re-plan against. Collapsing the two would either abort a recoverable turn or hide a client bug.

Tenant arguments are stripped from arguments before dispatch even though ToolCatalogue.execute takes the tenant separately and ignores the mapping — defence in depth, so a future tool cannot accidentally honour a caller-supplied tenant.

6. Monitor fires, digest published

stateDiagram-v2
    [*] --> ok
    ok --> pending: condition breached
    pending --> pending: breaches < consecutiveBreaches
    pending --> firing: breaches reached
    pending --> ok: condition cleared
    firing --> firing: still breached, within cooldown (no new digest)
    firing --> resolved: condition cleared
    resolved --> ok: acknowledged / next sweep
    firing --> [*]: monitor deleted
sequenceDiagram
    autonumber
    participant W as Worker
    participant CM as CommandService
    participant MS as MonitorStore
    participant Q as QueryService
    participant EV as EventPublisher

    loop every WA_MONITOR_INTERVAL_SECONDS
        W->>MS: tenants()
        loop per tenant (isolated)
            W->>CM: evaluate_monitors(tenant)
            CM->>MS: monitors(tenant)
            CM->>Q: run each saved query
            CM->>CM: domain state machine → transition
            alt transition to firing and outside cooldown
                CM->>CM: build Digest with supporting docIds
                CM->>CM: fingerprint → dedupe
                CM->>EV: digest.published
            end
        end
    end

Per-tenant isolation is explicit: one tenant's broken monitor must not stop every other tenant's alerts from being evaluated, and a sweep that throws is logged and followed by the next sweep. A worker that exits on a transient database blip is worse than one that skips a cycle.

Digests carry the docIds that triggered them. An alert that says "billing complaints are up" without the records is the same unverifiable claim the whole system exists to prevent.

7. The eval harness

flowchart LR
    ES["EvalSet<br/>cases with expected<br/>docIds / refusal"] --> RUN["runEval"]
    RUN --> ASK["ask() per case"]
    ASK --> GRADE["grade_case"]
    GRADE --> M1["exactRate"]
    GRADE --> M2["hallucinationRate"]
    GRADE --> M3["refusalCorrectness"]
    GRADE --> M4["citationPrecision / recall"]
    GRADE --> M5["groundedness"]
    GRADE --> M6["p50 / p99 latency"]
    M1 & M2 & M3 & M4 & M5 & M6 --> AGG["aggregate"]
    AGG --> GATE{"ScoreGate"}
    GATE -->|pass| OK["passed: true"]
    GATE -->|fail| BAD["passed: false<br/>+ named failing cases"]
    OK & BAD --> MET["wa_eval_runs_total{result}<br/>wa_eval_score{metric}"]

refusalCorrectness is the metric that keeps the rest honest. Refusals are scored against cases that should be refused, so the trivial way to win on hallucinationRate — refuse everything — loses badly here.


Graceful degradation

Nothing outside the process is required. Each dependency degrades to a deterministic in-process path, and the full test suite runs on the degraded path, which is what stops it from bit-rotting.

Dependency

Configured with

Absent ⇒

Consequence

Postgres

WA_DATABASE_DSN

In-memory repository

Single node, no durability across restarts. Same port, same tests

Kafka

WA_KAFKA_BROKERS

In-process publisher

Events still fan out to subscribers; nothing leaves the process

Redis

WA_REDIS_URL

No cache

Slower repeats. A cache is never load-bearing for correctness

LLM

WA_LLM_BASE_URL

Heuristic planner + extractive drafter + rubric judge

Answers are extractive quotes rather than prose. Every citation guarantee is unchanged

OTLP collector

WA_OTLP_ENDPOINT

No-op tracer

Metrics and logs are unaffected

Degradation is also dynamic, not just a startup decision: when the LLM is configured but its circuit breaker opens, the saga falls back to the offline drafter mid-flight and reports which one produced the answer (answer.model — see the real output below, where it reads "extractive").

The design consequence worth stating plainly: the offline path is not a stub. Retrieval, fusion, reranking, grounding verification, refusal, monitors and evals are all fully implemented in pure Python. The LLM upgrades fluency, not correctness.


Data model, partitioning and sharding

See db/migrations/0001_init.sql.

erDiagram
    DOCUMENTS ||--o{ AGENT_TRACES : cited_by
    MONITORS ||--o{ MONITOR_STATUS : has
    MONITORS ||--o{ DIGESTS : emits
    EVAL_SETS ||--o{ EVAL_REPORTS : scored_by

    DOCUMENTS {
        text doc_id PK
        text tenant_id
        smallint shard
        text source
        text theme_id
        timestamptz occurred_at PK
    }
    AGENT_TRACES {
        text run_id PK
        text tenant_id
        timestamptz created_at PK
        bool refused
        jsonb steps
    }
    MONITORS {
        text monitor_id PK
        text tenant_id
        text query
        jsonb condition
    }
    DIGESTS {
        text digest_id PK
        text monitor_id FK
        text dedupe_key
        jsonb doc_ids
    }

Two orthogonal axes, because they answer different questions:

  • Range partitioning by occurred_at (monthly). Feedback analysis is overwhelmingly recent-window: "what changed this quarter". Monthly partitions let the planner prune everything else, and retention becomes DROP PARTITION rather than a DELETE that bloats the table.

  • Hash sharding by tenant into WA_TENANT_SHARDS buckets. Prevents one large customer from turning into a hot partition. The shard is computed by domain/partition.py and stored as a column, so the same function decides placement in the application and in SQL — they cannot disagree.

/ready reports shard_balance. On a single-tenant demo it reads 0.25 with four shards, which is correct and expected: one tenant occupies exactly one bucket. A multi-tenant deployment where it drifts toward 0 is the hot-tenant failure mode, and there is an alert for it.

The composite primary key is (doc_id, occurred_at) because Postgres requires the partition key to be part of every unique constraint.


Resilience

Every outbound I/O call goes through one ResiliencePolicy[T], composed in a fixed order:

flowchart LR
    CALL["Call"] --> BH["Bulkhead<br/>bounded concurrency"]
    BH --> CB{"Circuit breaker"}
    CB -->|open| FAST["Fail fast<br/>→ fallback"]
    CB -->|closed / half-open| TO["Timeout"]
    TO --> RETRY["Retry<br/>exponential backoff + jitter"]
    RETRY --> OP["Operation"]
    OP -->|failure| CB
    OP -->|success| DONE["Result"]

The order is not arbitrary:

  • Bulkhead outermost — the concurrency cap has to apply to waiting callers too, or a slow dependency backs up unbounded queues of tasks that are all going to time out anyway.

  • Breaker before timeout — when a dependency is known-dead, failing fast is strictly better than waiting the full timeout to learn it again.

  • Timeout inside retry — each attempt gets its own deadline; a retry that inherits an already-expired budget is a wasted call.

  • Jitter always — synchronised retries from many replicas are how a recovering dependency gets knocked back down.

Applied to: Postgres queries, Redis get/set, Kafka publishes, LLM completions, and every MCP tool dispatch. Breaker state is exported per dependency as wa_circuit_breaker_state, and both CircuitBreakerOpen and RetryStorm are alerting rules — with an inhibition so an open breaker suppresses the retry-storm page it necessarily causes.


Getting started

Local, no infrastructure

git clone https://github.com/ABHIJEET-MUNESHWAR/wisdomagent.git
cd wisdomagent
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

# tests, lint, benchmark — none of these need a container
python -m pytest -q
python -m ruff check . && python -m ruff format --check .
python -m benchmarks.bench_retrieval

# run it, seeded with a synthetic corpus
WA_BOOTSTRAP_DEMO=true python -m wisdomagent.main
# → http://localhost:8000/graphql

Full stack

docker compose up --build

Service

URL

Notes

API

http://localhost:8000/graphql

GraphiQL in the browser

MCP

http://localhost:8000/mcp

JSON-RPC 2.0

Prometheus

http://localhost:9090

27 alert rules loaded

Grafana

http://localhost:3000

admin / admin, two dashboards provisioned

Alertmanager

http://localhost:9093

Routing tree, 4 inhibition rules

Jaeger

http://localhost:16686

Traces via the OTLP collector

Worker metrics

http://localhost:8001/metrics

The worker's only HTTP surface

The compose stack additionally starts Postgres (with the migration applied on first boot), Redis and Kafka, so the partitioned schema, the answer cache and the event bus are all exercised for real rather than stubbed.


Using it

GraphQL

curl -sS localhost:8000/graphql \
  -H 'content-type: application/json' \
  -H 'x-tenant-id: acme' \
  -d '{"query":"query { ask(question: \"why are customers unhappy with billing\", topK: 6) { answer { text refused confidence model citedDocuments claims { text citations { docId } } } grounding { groundedness citationPrecision coverage accepted } hits { docId score lexicalScore vectorScore rank } } }"}'

Real response from a demo-seeded instance (200 synthetic documents, offline path, abridged):

{
  "answer": {
    "text": "Billing page is slow and the payment method dropdown fails to load (report 46) Still happening. Billing page is slow and the payment method dropdown fails to load (report 81) Third time this week. Billing page is slow and the payment method dropdown fails to load (report 76) ...",
    "refused": false,
    "confidence": 0.75,
    "model": "extractive",
    "citedDocuments": ["doc-00046", "doc-00081", "doc-00076", "doc-00086", "doc-00051", "doc-00016"],
    "claims": [
      {
        "text": "Billing page is slow and the payment method dropdown fails to load (report 46)",
        "citations": [{ "docId": "doc-00046" }]
      },
      {
        "text": "Still happening. Billing page is slow and the payment method dropdown fails to load (report 81)",
        "citations": [{ "docId": "doc-00081" }]
      }
    ]
  },
  "grounding": {
    "groundedness": 1.0,
    "citationPrecision": 1.0,
    "coverage": 1.0,
    "accepted": true
  },
  "hits": [
    { "docId": "doc-00046", "score": 1.221095, "lexicalScore": 3.525865, "vectorScore": 0.189384, "rank": 1 },
    { "docId": "doc-00081", "score": 1.100036, "lexicalScore": 3.269024, "vectorScore": 0.187094, "rank": 2 },
    { "docId": "doc-00076", "score": 1.071927, "lexicalScore": 3.154142, "vectorScore": 0.139294, "rank": 3 },
    { "docId": "doc-00086", "score": 1.056670, "lexicalScore": 3.154142, "vectorScore": 0.138375, "rank": 4 }
  ]
}

"model": "extractive" is the offline drafter: no LLM was configured, so claims are verbatim spans. The citation guarantee is identical either way — only the prose changes.

A refusal

Ask something the corpus cannot support and the system says so, with the reason and the gate that stopped it. Real response:

{
  "answer": {
    "text": "I cannot answer that from the indexed feedback.",
    "refused": true,
    "refusalReason": "the retrieved feedback does not discuss this topic (relevance 0.12)",
    "confidence": 0.0
  },
  "grounding": {
    "groundedness": 0.0,
    "accepted": false,
    "failureReason": "the draft contained no verifiable claims"
  }
}

Note that this is HTTP 200 with no errors member. A refusal is a successful outcome, not a fault.

MCP (JSON-RPC 2.0)

curl -sS localhost:8000/mcp \
  -H 'content-type: application/json' -H 'x-tenant-id: acme' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"aggregate_by_theme","arguments":{"query":"billing","limit":4}}}'

Real response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"query\": \"billing\", \"themes\": [{\"theme_id\": \"billing\", \"count\": 35, \"doc_ids\": [\"doc-00081\", \"doc-00086\", \"doc-00046\", \"doc-00051\", \"doc-00076\"]}, {\"theme_id\": \"reliability\", \"count\": 21, \"doc_ids\": [\"doc-00050\", \"doc-00055\", \"doc-00130\", \"doc-00185\", \"doc-00105\"]}, {\"theme_id\": \"onboarding\", \"count\": 19, \"doc_ids\": [\"doc-00093\", \"doc-00058\", \"doc-00178\", \"doc-00053\", \"doc-00048\"]}, {\"theme_id\": \"performance\", \"count\": 15, \"doc_ids\": [\"doc-00184\", \"doc-00139\", \"doc-00074\", \"doc-00049\", \"doc-00039\"]}]}"
      }
    ],
    "isError": false
  }
}

Every aggregate carries doc_ids. A count without its records is exactly the kind of number that ends up in a slide deck and cannot be checked.

The four tools: search_feedback, aggregate_by_theme, trend_over_time, get_account_context.

Ops endpoints

curl -sS localhost:8000/ready

Real response:

{
  "status": "ready",
  "corpus": "ready",
  "documents": 200,
  "vocabulary": 349,
  "vectors": 200,
  "traces": 2,
  "shard_balance": 0.25,
  "fusion_strategy": "rrf"
}

/health is liveness only and touches no dependency. /ready is the strictly stronger claim a rollout should gate on — and note that an empty corpus is still ready, because a service that correctly refuses every question is working as designed; reporting not-ready there would block the rollout of a freshly provisioned tenant.

Postman

postman/WisdomAgent.postman_collection.json — import and run top to bottom. It covers every GraphQL query and mutation, the MCP handshake, all four tools/calls, batching, all four JSON-RPC error codes, the ops endpoints, and instructions for the WebSocket subscription. Requests capture runId / docId into collection variables, so nothing needs hand-editing.

Every GraphQL document in the collection is validated against the live schema, and every request has been executed against the real application.


Configuration

All settings are WA_-prefixed and parsed once at startup into a frozen Settings. An unparseable value is a ConfigError at boot, not a TypeError under load.

Variable

Default

Notes

WA_HOST / WA_PORT

0.0.0.0 / 8000

WA_ENVIRONMENT

local

Label on every log line and metric

WA_LOG_JSON / WA_LOG_LEVEL

true / INFO

WA_DEFAULT_TENANT

default

Used when x-tenant-id is absent

WA_TENANT_SHARDS

4

Hash-shard count

WA_PARTITION_SPAN_MONTHS

1

Range-partition width

WA_DATABASE_DSN

(blank)

Blank ⇒ in-memory repository

WA_KAFKA_BROKERS / WA_KAFKA_TOPIC

(blank) / wisdomagent.events

Blank ⇒ in-process publisher

WA_REDIS_URL / WA_CACHE_TTL_SECONDS

(blank) / 300

Blank ⇒ no cache

WA_LLM_BASE_URL / WA_LLM_API_KEY / WA_LLM_MODEL

(blank) / (blank) / gpt-4o-mini

Blank ⇒ deterministic offline path

WA_OTLP_ENDPOINT

(blank)

Blank ⇒ no-op tracer

WA_FUSION_STRATEGY

rrf

rrf or weighted

WA_EMBEDDING_DIM / WA_EMBEDDING_SEED

128 / 1337

Determinism knobs

WA_NGRAM_SIZE / WA_RRF_K

4 / 60

Character n-grams; RRF constant

WA_BM25_K1 / WA_BM25_B

1.2 / 0.75

WA_DEFAULT_TOP_K / WA_MAX_TOP_K

10 / 100

Oversized requests are clamped, not rejected

WA_CANDIDATE_MULTIPLIER

4

Retrieval depth before reranking

WA_AGENT_MAX_STEPS / WA_AGENT_MAX_TOKENS

8 / 8000

Agent budget ceilings

WA_AGENT_MAX_WALL_CLOCK_SECONDS

30.0

WA_AGENT_MAX_CALLS_PER_TOOL / WA_AGENT_MAX_REPAIRS

3 / 1

WA_MIN_SUPPORT / WA_MIN_GROUNDEDNESS

0.6 / 0.7

Verification thresholds

WA_MIN_EVIDENCE_RELEVANCE

0.25

The refusal floor

WA_MAX_CLAIMS / WA_EVIDENCE_LIMIT

12 / 8

What bounds the grounding cost

WA_TIMEOUT_SECONDS / WA_RETRY_MAX_ATTEMPTS

5.0 / 3

Resilience

WA_BREAKER_FAILURE_THRESHOLD / WA_BREAKER_OPEN_SECONDS

5 / 10.0

WA_BULKHEAD_MAX_CONCURRENT

32

WA_RATE_LIMIT_CAPACITY / WA_RATE_LIMIT_REFILL_PER_SECOND

200 / 100.0

WA_MONITOR_INTERVAL_SECONDS

300.0

Worker sweep cadence

WA_WORKER_METRICS_PORT

8001

Worker's only HTTP surface

WA_MCP_MAX_OUTPUT_CHARS / WA_MCP_CALL_TIMEOUT_SECONDS

20000 / 10.0

WA_BOOTSTRAP_DEMO

(unset)

Seeds a synthetic corpus and eval set

Settings.describe() is the safe-to-log view. It never includes the API key.


Observability

Structured JSON logs. One object per line, with a correlation id propagated through contextvars so it survives await boundaries without being threaded through every signature. Accepted from x-correlation-id and echoed back on every response.

Metrics — 33 collectors on a private CollectorRegistry, exposed at /metrics:

  • REDwa_requests_total{operation,outcome}, wa_request_duration_seconds{operation}. operation is the route template, never the concrete path: labelling with /document/doc-00042 would mint a new time series per document and eventually kill the metrics backend.

  • USEwa_bulkhead_in_flight{dependency}, wa_circuit_breaker_state{dependency}, wa_rate_limited_total{operation}, wa_retries_total{dependency}, wa_corpus_documents, wa_corpus_terms.

  • Agent SLIswa_groundedness_score, wa_citation_precision, wa_unsupported_claims_total, wa_refusals_total{reason}, wa_repairs_total{outcome}, wa_agent_steps, wa_agent_tokens, wa_tool_calls_total{tool,outcome}, wa_tool_duration_seconds{tool}, wa_saga_steps_total{step,outcome}, wa_questions_total{outcome}.

  • Domainwa_documents_indexed_total, wa_documents_rejected_total{reason}, wa_searches_total{strategy}, wa_search_hits, wa_eval_score{metric}, wa_monitors_evaluated_total, wa_monitor_transitions_total{transition}, wa_digests_published_total, wa_mcp_calls_total{method,outcome}, wa_events_published_total{event_type,outcome}, wa_cache_total{result}.

Alertsmonitoring/alerts.yml, 27 rules in 6 groups, every one with severity, component, summary, description and a runbook annotation. Every rule name is prefixed WisdomAgent… (elided below for width):

Group

Rules

wisdomagent-slo

ApiDown, ErrorBudgetBurnFast, ErrorBudgetBurnSlow, AskLatencyHigh

wisdomagent-correctness

GroundednessCollapsed, HallucinationRateHigh, CitationPrecisionLow, RefusalRateHigh, EvalGateFailing, EvalScoreRegressed

wisdomagent-agent

BudgetExhaustionFrequent, ToolErrorRateHigh, StepsPerRunHigh, RepairsFailing, McpErrorRateHigh

wisdomagent-pipeline

CorpusEmpty, IngestRejectionsHigh, MonitorsNotEvaluating, DigestStorm, EventPublishFailures

wisdomagent-resilience

CircuitBreakerOpen, RetryStorm, BulkheadSaturated, RateLimitingClients

wisdomagent-capacity

CacheHitRateLow, IndexBuildSlow, SearchReturningNothing

Alertmanager routes critical → on-call and warning → a lower-urgency channel, with four inhibition rules so one root cause pages once: ApiDown suppresses everything else, CorpusEmpty suppresses the refusal/empty-search/groundedness alerts it necessarily causes, and CircuitBreakerOpen suppresses RetryStorm and RepairsFailing.

Receiver URLs use url_file rather than ${ENV}, because Alertmanager does not expand environment variables in its config — a literal url: ${SLACK_WEBHOOK_URL} fails validation with unsupported scheme "". See monitoring/secrets/README.md.

All of this is machine-validated in CI with promtool check rules, promtool check config and amtool check-config, so a typo in a PromQL expression fails the build instead of failing silently during an incident.

Dashboards — two provisioned Grafana boards: WisdomAgent / Service (RED and USE) and WisdomAgent / Grounding and Agent SLIs (groundedness, hallucination rate, citation precision, refusal reasons, repair outcomes, agent step mix, eval scores and monitor activity).

Tracing — OTLP to the collector, with tail sampling that keeps 100% of errors, slow spans (>5 s), refusals and ungrounded answers, and 10% of everything else. The interesting traces are exactly the rare ones; uniform sampling throws them away.


Complexity

N = corpus size, L = mean document length in tokens, d = embedding dimension (128), k = candidate depth, C = claims, E = evidence documents, F = rerank features, T = tokens per evidence document.

Operation

Time

Space

Notes

Tokenize corpus

O(N·L)

O(N·L)

Single pass, fold + strip + n-grams

BM25 index build

O(N·L)

O(V + postings)

V = vocabulary

BM25 query

O(|q|·postings)

O(k)

Sub-linear in N in practice: postings for a discriminative term are short

Embed batch

O(N·L·d/64)

O(N·d)

Hashed features, no model call

ANN index build

O(N·d)

O(N·d)

float32 matrix, contiguous

ANN query (flat)

O(N·d)

O(N)

One matvec

ANN query (IVF-lite)

O(probes·N/cells·d)

O(N)

Cell centroids + inverted lists

RRF fusion

O(k log k)

O(k)

Sort dominates

Weighted fusion

O(k log k)

O(k)

Sort dominates

Feature rerank

O(k·F)

O(k)

F fixed at 4

Grounding check

O(C·E·T)

O(C·E)

The verification cost, bounded by max_claims and evidence_limit

Full ask

O(N + k log k)

O(N·d)

Retrieval dominates; everything after it is bounded by k

Shard resolution

O(1)

O(1)

Hash

Partition resolution

O(1)

O(1)

Date arithmetic

Monitor sweep

O(M·ask)

O(M)

M = monitors for the tenant


Benchmarks

benchmarks/bench_retrieval.py measures 11 kernels at four corpus sizes and fits a scaling exponent to log(time) vs log(size) by least squares, then compares it against the documented claim. --check fails the build when a kernel exceeds its limit, which catches an accidental O(N²) that a unit test — which only ever sees a handful of documents — cannot.

It also reports NOISE rather than a verdict when a kernel's timings are below a 0.05 ms floor or when the input size does not actually vary, because grading a constant-time measurement produces an exponent of 0.00 and a meaningless PASS.

Real output, python -m benchmarks.bench_retrieval (Python 3.12, WSL2 on x86-64, GC disabled during measurement, p50/p95 over repeated samples):

kernel                 docs   items   mean ms   p95 ms   us/item       items/s
------------------------------------------------------------------------------
tokenize_corpus         500     500     5.371    5.751    10.743        93,085
tokenize_corpus        1000    1000    10.444   10.974    10.444        95,750
tokenize_corpus        2000    2000    20.899   22.336    10.449        95,700
tokenize_corpus        4000    4000    43.262   44.291    10.815        92,460
bm25_build              500     500     7.209    7.926    14.418        69,359
bm25_build             1000    1000    14.761   16.368    14.761        67,746
bm25_build             2000    2000    27.685   28.535    13.842        72,242
bm25_build             4000    4000    60.504   63.112    15.126        66,111
bm25_query              500     500     0.136    0.155     0.272     3,670,883
bm25_query             1000    1000     0.178    0.186     0.178     5,608,273
bm25_query             2000    2000     0.225    0.254     0.113     8,871,532
bm25_query             4000    4000     0.350    0.362     0.087    11,437,813
embed_batch             500     500    53.876   56.313   107.751         9,281
embed_batch            1000    1000   108.840  111.994   108.840         9,188
embed_batch            2000    2000   216.628  218.959   108.314         9,232
embed_batch            4000    4000   456.180  466.971   114.045         8,768
ann_build               500     500     1.770    1.867     3.540       282,524
ann_build              1000    1000     3.684    4.424     3.684       271,425
ann_build              2000    2000     8.091    9.275     4.046       247,182
ann_build              4000    4000    15.837   16.645     3.959       252,569
ann_query               500     500     0.425    1.325     0.850     1,175,898
ann_query              1000    1000     0.188    0.199     0.188     5,333,100
ann_query              2000    2000     0.242    0.286     0.121     8,280,888
ann_query              4000    4000     0.248    0.292     0.062    16,129,878
rrf_fuse                500      50     0.181    0.250     3.626       275,779
rrf_fuse               1000     100     0.084    0.101     0.837     1,195,183
rrf_fuse               2000     200     0.191    0.301     0.956     1,046,010
rrf_fuse               4000     400     0.317    0.372     0.793     1,261,713
weighted_fuse           500      50     0.106    0.119     2.115       472,812
weighted_fuse          1000     100     0.084    0.088     0.837     1,194,963
weighted_fuse          2000     200     0.184    0.188     0.920     1,087,325
weighted_fuse          4000     400     0.369    0.414     0.921     1,085,319
rerank                  500      50     0.779    1.095    15.588        64,150
rerank                 1000     100     1.356    1.404    13.557        73,763
rerank                 2000     200     2.845    3.112    14.225        70,301
rerank                 4000     400     5.475    5.885    13.689        73,053
grounding_check         500       8     0.120    0.124    14.980        66,756
grounding_check        1000      10     0.121    0.131    12.066        82,874
grounding_check        2000      20     0.126    0.137     6.290       158,990
grounding_check        4000      40     0.114    0.117     2.839       352,288
agent_ask               500     500     3.089    4.207     6.178       161,863
agent_ask              1000    1000     2.788    3.105     2.788       358,636
agent_ask              2000    2000     3.141    3.382     1.571       636,726
agent_ask              4000    4000     3.039    3.191     0.760     1,316,299

kernel              claim                                   exponent   limit  verdict
-------------------------------------------------------------------------------------
tokenize_corpus     O(N * L)                                    1.00    1.35  PASS
bm25_build          O(N * L)                                    1.01    1.40  PASS
bm25_query          O(|q| * postings)                           0.44    1.35  PASS
embed_batch         O(N * L)                                    1.02    1.35  PASS
ann_build           O(N * d)                                    1.06    1.45  PASS
ann_query           O(N * d) flat, O(probes * N/cells * d) IVF     -0.20    1.35  PASS
rrf_fuse            O(k log k)                                  0.36    1.30  PASS
weighted_fuse       O(k log k)                                  0.65    1.30  PASS
rerank              O(k * F)                                    0.95    1.30  PASS
grounding_check     O(C * E * T)                               -0.03    1.30  PASS
agent_ask           O(N + k log k) per run                      0.01    1.40  PASS

Reading these honestly:

  • bm25_query at 0.44 and ann_query at −0.20 are sub-linear because the measured work does not scale with N the way the worst case does — postings for a discriminative term stay short, and the ANN mat-vec is memory-bandwidth-bound long before it is compute-bound at these sizes.

  • grounding_check at −0.03 is flat because it is bounded by WA_MAX_CLAIMS and WA_EVIDENCE_LIMIT, not by corpus size. That is the design working: verification cost does not grow with the corpus.

  • agent_ask at 0.01 for an end-to-end run is the same effect — retrieval is the only N-dependent term and it is cheap relative to the fixed pipeline cost at these sizes.

  • The fusion exponents are the least trustworthy numbers here. rrf_fuse fits 0.36 in this run and 1.02 in the previous one, purely because its smallest data point (0.181 ms mean, 0.250 ms p95 at 500 documents) caught an outlier and dragged the fit. Both are well inside the 1.30 limit and both are consistent with the O(k log k) claim, but a least-squares fit over four points near the noise floor is a weak instrument, and I would not quote either figure to two decimal places.

That last bullet is the honest caveat on the whole method: the exponents are a regression detector, not a measurement. They reliably catch the 1.6-vs-1.0 kind of change that a cache cliff or an accidental nested loop produces. They do not resolve 0.36 from 1.02 on a kernel that runs in a third of a millisecond, which is exactly why the harness reports NOISE instead of a verdict when a kernel never leaves the 0.05 ms floor.

Absolute timings are machine- and load-dependent and should not be compared across runs on different hardware.

A real bug this found

ann_query originally failed its gate. The kernel measured 0.16 ms at 2 000 documents and 3.4 ms at 4 000 — a discontinuous jump, not a growth curve, which is the signature of a cache cliff rather than an algorithmic problem. Profiling put all of it inside the matrix @ query mat-vec.

Two causes, compounding. For a tall-and-skinny mat-vec, OpenBLAS's dgemv spends more time coordinating threads than multiplying; and the float64 matrix crosses out of cache somewhere past 2 000 rows, so the cost jumps rather than growing smoothly.

The fix in wisdomagent/domain/vector.py was to materialise the index as a contiguous float32 matrix (half the bytes ⇒ twice as many vectors resident in cache) and to compute similarities with np.einsum("ij,j->i", …) instead of @, sidestepping the BLAS gemv path entirely. Post-fix, the same kernel is the flat 0.425 / 0.188 / 0.242 / 0.248 ms row in the table above — an order of magnitude faster at 4 000 documents, and no longer growing.

This is the argument for having a complexity gate at all. Every unit test passed before and after; nothing was functionally wrong. The suite runs on tens of documents, where the cliff is invisible.


Test suite

893 tests, 91% line coverage, no infrastructure required — the whole suite runs against in-memory adapters and an injectable FixedClock, in about 3 seconds.

Real output:

$ python -m ruff check . && python -m ruff format --check .
All checks passed!
87 files already formatted

$ python -m pytest -q
893 passed, 1 warning in 3.85s
$ python -m pytest --cov=wisdomagent --cov-report=term-missing -q | tail -30
wisdomagent/app/messages.py                138     10    93%   227-228, 231-232, 239-240, 243-244, 247-248
wisdomagent/app/ports.py                    41      3    93%   163-166
wisdomagent/app/service.py                 424     56    87%   217-218, 278-282, 320-322, 376, 378, 408-410, 464, 507, 533-561, 579, 621-643, 674-676, 732, 779, 843, 848-856, 860
wisdomagent/config/__init__.py               2      0   100%
wisdomagent/config/settings.py             195     23    88%   116, 118, 122, 126, 160, 162, 170, 172, 174, 176, 325, 329, 331, 335, 339, 341, 343, 345, 347, 349, 351, 353, 355
wisdomagent/container.py                   118     11    91%   222, 237, 253, 260-272
wisdomagent/domain/__init__.py              14      0   100%
wisdomagent/domain/budget.py               102      5    95%   49, 133, 163, 167-168
wisdomagent/domain/errors.py                39      0   100%
wisdomagent/domain/evaluation.py           140      6    96%   45, 47, 51, 70, 72, 264
wisdomagent/domain/events.py                97     27    72%   65, 67, 69, 73, 78, 93, 97-112, 155, 161, 167, 173, 179
wisdomagent/domain/fusion.py                76      5    93%   78, 82, 101, 105, 119
wisdomagent/domain/grounding.py            152     15    90%   68-73, 76, 139, 147, 163, 190, 249, 251, 253, 274
wisdomagent/domain/lexical.py              109     15    86%   66, 68, 96-97, 105, 125-127, 134, 181, 214-218
wisdomagent/domain/model.py                308     20    94%   50, 56, 203-211, 215, 242, 247, 249, 276, 282, 361, 446, 448
wisdomagent/domain/monitors.py             178      8    96%   99-102, 123, 125, 127, 332
wisdomagent/domain/partition.py             59      0   100%
wisdomagent/domain/planner.py               97      3    97%   142, 171, 217
wisdomagent/domain/rerank.py               105     10    90%   64, 84, 104, 107, 116, 128, 142, 188, 223, 253
wisdomagent/domain/tokenize.py              82     14    83%   79, 95, 105, 127-136, 143
wisdomagent/domain/vector.py               174     53    70%   69, 71, 122, 124, 126, 138, 155-159, 164, 174-177, 200, 212-231, 241, 254-255, 257-261, 263, 282-285, 288, 299-306, 317-319
wisdomagent/observability/__init__.py        4      0   100%
wisdomagent/observability/logging.py        48      0   100%
wisdomagent/observability/metrics.py       115      0   100%
wisdomagent/resilience/__init__.py           2      0   100%
wisdomagent/resilience/resilience.py       203      7    97%   67, 86, 88, 136, 162, 242, 313
wisdomagent/worker.py                       76     13    83%   111-124, 128, 132
----------------------------------------------------------------------
TOTAL                                     4712    422    91%
893 passed, 1 warning in 7.23s

Tests are named as behavioural statements (test_every_claim_cites_a_retrieved_document, test_refuses_an_unanswerable_question, test_every_recorded_step_is_also_counted) and grouped in classes by the guarantee they defend. Roughly a third of them are failure-path tests, because the failure paths are where a grounded-answer system earns its keep.


Project layout

wisdomagent/
  domain/          15 modules — pure logic, zero I/O imports
  app/             ports, CQRS services, AnswerSaga, mediator
  adapters/        memory, synthetic, postgres, messaging, redisx, agent/, mcp/
  api/             FastAPI app, GraphQL schema, mappers, request context
  observability/   structured logging, metrics registry, tracing
  resilience/      timeout, retry, circuit breaker, bulkhead, rate limiter
  config/          settings parsing and validation
  container.py     composition root
  main.py          API entrypoint
  worker.py        monitor scheduler entrypoint
tests/             24 files, 893 tests (incl. an architecture test
                   asserting the domain imports no infrastructure)
benchmarks/        complexity-gated performance suite
db/migrations/     partitioned + sharded schema
monitoring/        prometheus, 27 alerts, alertmanager, otel, 2 grafana dashboards
postman/           importable collection, schema-validated

Trade-offs and known gaps

Stated plainly, because a README that only lists strengths is not useful.

  • The default embedder is hashed, not learned. It is deterministic, needs no model download and makes the eval harness reproducible — but it captures far less semantic similarity than a real sentence encoder. The SearchIndex port exists so a learned embedder is a drop-in replacement.

  • The offline drafter is extractive. It quotes rather than composes, so answers read as a list of verbatim spans. This is why answer.model is returned: consumers can tell which path produced the text.

  • The worker does not consume Kafka. It publishes digest events and runs the monitor scheduler, but the consumer-side projection is not implemented. The publisher and topic configuration are real; nothing reads them back.

  • Grounding uses lexical span containment, not entailment. It cannot detect a claim that is semantically implied but not lexically overlapping, and it can over-credit a paraphrase that reuses vocabulary. A cross-encoder NLI model would be strictly better and strictly slower; the GroundingChecker is a single class behind a stable interface.

  • The GraphQL surface has no authentication. Tenancy is a header, with an explicit note in Context.tenant_or that a real multi-customer deployment must authenticate it and reject mismatched overrides. This is a deliberate scope boundary, not an oversight.

  • domain/events.py sits at 72% coverage, the lowest in the tree — the event constructors are mostly data plumbing and several variants are only exercised indirectly.

  • The complexity gate measures a single machine. Exponents are robust to hardware; the absolute numbers in this README are not, and CI deliberately gates on the former only.


License

MIT — see LICENSE.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • Agent-native MCP server over the public saagarpatel.dev corpus. Read-only, stateless.

  • Local-first RAG engine with MCP server for AI agent integration.

  • MCP server exposing the Backtest360 engine API as tools for AI agents.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ABHIJEET-MUNESHWAR/WisdomAgent'

If you have feedback or need assistance with the MCP directory API, please join our Discord server