Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Common developer tasks. Run `make help` for the list.

.PHONY: help install demo acl-demo test lint format format-check typecheck redis-up redis-down check
.PHONY: help install demo acl-demo structured-demo fatslim-demo flat-demo test lint format format-check typecheck redis-up redis-down check

help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
Expand All @@ -21,6 +21,9 @@ structured-demo: ## Show the structured query path compiling to RediSearch
fatslim-demo: ## Show the fat/slim retrieval split (slim search, fat fetch)
uv run python -m meridian.interfaces.cli.main --fatslim-demo

flat-demo: ## Show the flat, pre fat/slim knowledge-chunk model, for contrast
uv run python -m meridian.interfaces.cli.main --flat-demo

test: ## Run the test suite
uv run pytest

Expand Down
59 changes: 49 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ uv run python -m meridian.interfaces.cli.main --acl-demo
ACL probe: retrieving 'security post mortem for the payments outage root cause'

[carol groups=security ] -> Security Post-Mortem, Credential Rotation Guide
[alice groups=payments,platform ] -> Payments Service Auth Guide, Database Failover Runbook, ...
[alice groups=payments,platform ] -> Payments Service Auth Guide, Transfer API Reference, Database Failover Runbook
[dan groups=(no groups) ] -> (nothing visible)
```

Expand Down Expand Up @@ -180,12 +180,12 @@ fat/slim probe: 'how do I configure authentication...' as alice

Phase 1 - slim search (cheap, projections only):
· Payments Service Authentication [snippet: To configure authentication for the payments...]
· Database Failover Procedure [snippet: For a database failover, first confirm...]
· Gateway Rate Limiting [snippet: The gateway rate limiter uses a token bucket...]
· Credential Rotation [snippet: To rotate a service credential, generate a new...]
· Database Failover Procedure [snippet: For a database failover, first confirm the...]

Phase 2 - fat fetch (JSON.GET) for survivors only:
· Payments Service Authentication owner=payments updated=2025-11-02 chars=394
· Database Failover Procedure owner=sre updated=2025-12-01 chars=371
· Payments Service Authentication owner=payments updated=2026-02-02 chars=687
· Credential Rotation owner=platform updated=2026-01-30 chars=718
```

The **slim projection** (title, snippet, source, ACL) is an indexed hash the KNN
Expand All @@ -198,6 +198,35 @@ Each stage gets the payload it needs, right-sized end to end.

---

## Before the split: a flat retrieval model

Alongside the fat/slim index, the same knowledge is also seeded into a
simpler, older model: one flat `KnowledgeChunk` record per document, full
text and citation metadata together, no separate `JSON.GET` fetch. It exists
to make the fat/slim split's cost trade-off concrete by comparison, and it
carries the identical access-control guarantee.

```bash
uv run python -m meridian.interfaces.cli.main --flat-demo
```

```
flat-chunk probe: 'security post mortem for the payments outage root cause'

[carol groups=security ] -> Security Post-Mortem, Credential Rotation Guide
[alice groups=payments,platform ] -> Payments Service Auth Guide, Database Failover Runbook, Transfer API Reference
[dan groups=(no groups) ] -> (nothing visible)
```

Same probe, same ACL filter applied inside the search - `search_chunks`
instead of `search_slim` - and the same outcome: Carol sees the restricted
post-mortem, Alice never does, Dan sees nothing. The difference is cost, not
security: every candidate here carries its full text into the search index,
so ranking pays for the whole document on every candidate, not just the
survivors. That's the bill the fat/slim split above exists to avoid.

---

## Real DSPy on Groq, with a fake fallback by default

The routing and generation contracts are backed by **real DSPy modules** when you
Expand Down Expand Up @@ -291,16 +320,12 @@ make demo # scripted end-to-end demo
make acl-demo # access-control filter in isolation
make structured-demo # structured query compiled to RediSearch
make fatslim-demo # fat/slim retrieval split
make flat-demo # flat, pre fat/slim knowledge-chunk model
make test # test suite
make check # lint + typecheck + test
make redis-up # start Redis Stack
```

Conventions: everything in English, every module/class/public function
docstringed (Google style), full type hints, `ruff` for lint and format. The
agent harness in [`CLAUDE.md`](./CLAUDE.md) documents the architecture rules and
the guardrails that must not regress.

---

## Notes on scope
Expand All @@ -315,3 +340,17 @@ with a grounding reward) and runs once you install the `groq` extra and set
`GROQ_API_KEY`; without them the system falls back to the fake provider so the
default demo always runs. The Azure providers are scaffolded to the point where
the only missing piece is the external SDK call.

**Content sources.** The prose under `data/catalog/` is original writing for
this fictional demo, informed by publicly documented engineering practices:
retry/backoff-with-jitter, failover discipline, and SLO/error-budget framing
draw on ideas from Google's *Site Reliability Engineering* book (free at
sre.google/books, CC BY-NC-ND 4.0) and its blameless-postmortem culture;
token-bucket rate limiting draws on public CNCF/Envoy/Kubernetes
rate-limiting documentation (CC BY 4.0); the idempotency-key pattern draws on
publicly documented conventions used by major payment APIs; credential
rotation draws on OWASP secret-management guidance. No text is copied from
these sources - everything here is original paraphrase written for this
repository. The `wiki.internal/...` URLs in the fixtures are fictional
placeholders for an invented internal wiki, not links to the real sources
above.
43 changes: 32 additions & 11 deletions README.pt-BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ uv run python -m meridian.interfaces.cli.main --acl-demo
ACL probe: retrieving 'security post mortem for the payments outage root cause'

[carol groups=security ] -> Security Post-Mortem, Credential Rotation Guide
[alice groups=payments,platform ] -> Payments Service Auth Guide, Database Failover Runbook, ...
[alice groups=payments,platform ] -> Payments Service Auth Guide, Transfer API Reference, Database Failover Runbook
[dan groups=(no groups) ] -> (nothing visible)
```

Expand All @@ -126,7 +126,7 @@ uv run python -m meridian.interfaces.cli.main --structured-demo
- api-gateway (team platform, tier1)
```

O `ServiceQueryBuilder` (`application/query/`) classifica cada campo de filtro como **TAG** (exato), **TEXT** (fuzzy com regras de tokenização) ou **NUMERIC** (intervalo) e compila uma expressão RediSearch. Duas propriedades são estruturais: a cláusula de visibilidade é sempre prefixada e o resultado passa por um **sanitizador** que rejeita verbos de agregação, entrada excessivamente longa e caracteres de controle, falhando fechado para uma consulta impossível ainda escopada por ACL.
O `ServiceQueryBuilder` (`application/query/`) classifica cada campo de filtro como **TAG** (exato), **TEXT** (fuzzy com regras de tokenização) ou **NUMERIC** (intervalo) e compila uma expressão RediSearch. Duas propriedades são estruturais: a cláusula de visibilidade é sempre prefixada (quem chama não consegue construir uma consulta sem escopo), e o resultado passa por um **sanitizador** que rejeita verbos de agregação, entrada excessivamente longa e caracteres de controle, falhando fechado para uma consulta impossível ainda escopada por ACL. É a mesma lição, em código, que uma vez transformou uma abordagem de RAG sobre dados tabulares que não funcionava numa abordagem de texto-para-consulta: o padrão segue o formato dos dados.

---

Expand All @@ -142,16 +142,36 @@ uv run python -m meridian.interfaces.cli.main --fatslim-demo
fat/slim probe: 'how do I configure authentication...' as alice

Phase 1 - slim search (cheap, projections only):
· Payments Service Authentication [snippet: To configure authentication for the payments...]
· Database Failover Procedure [snippet: For a database failover, first confirm...]
· Gateway Rate Limiting [snippet: The gateway rate limiter uses a token bucket...]
· Payments Service Authentication [snippet: To configure authentication for the payments...]
· Credential Rotation [snippet: To rotate a service credential, generate a new...]
· Database Failover Procedure [snippet: For a database failover, first confirm the...]

Phase 2 - fat fetch (JSON.GET) for survivors only:
· Payments Service Authentication owner=payments updated=2025-11-02 chars=394
· Database Failover Procedure owner=sre updated=2025-12-01 chars=371
· Payments Service Authentication owner=payments updated=2026-02-02 chars=687
· Credential Rotation owner=platform updated=2026-01-30 chars=718
```

A **projeção slim** (título, snippet, fonte, ACL) é um hash indexado que o KNN retorna, pequeno, rápido, suficiente para ranquear e citar. O **documento fat** (texto completo mais metadados ricos) é um corpo RedisJSON buscado por `JSON.GET`, e apenas para os sobreviventes que entrarão no contexto de geração. O `RagPipeline` executa exatamente esse fluxo: `search_slim` → selecionar sobreviventes → `fetch_fat` - então o payload fat é pago poucas vezes por consulta, nunca uma vez por candidato.
A **projeção slim** (título, snippet, fonte, ACL) é um hash indexado que o KNN retorna - pequeno, rápido, suficiente para ranquear e citar. O **documento fat** (texto completo mais metadados ricos) é um corpo RedisJSON buscado por `JSON.GET`, e apenas para os sobreviventes que entrarão no contexto de geração. O `RagPipeline` executa exatamente esse fluxo: `search_slim` → selecionar sobreviventes → `fetch_fat` - então o payload fat é pago poucas vezes por consulta, nunca uma vez por candidato.

---

## Antes do split: um modelo de recuperação flat

Além do índice fat/slim, o mesmo conhecimento também é indexado num modelo mais simples e antigo: um único registro `KnowledgeChunk` flat por documento, com texto completo e metadados de citação juntos, sem um `JSON.GET` separado. Ele existe para tornar concreto, por comparação, o trade-off de custo que o split fat/slim resolve, e carrega a mesma garantia de controle de acesso.

```bash
uv run python -m meridian.interfaces.cli.main --flat-demo
```

```
flat-chunk probe: 'security post mortem for the payments outage root cause'

[carol groups=security ] -> Security Post-Mortem, Credential Rotation Guide
[alice groups=payments,platform ] -> Payments Service Auth Guide, Database Failover Runbook, Transfer API Reference
[dan groups=(no groups) ] -> (nothing visible)
```

Mesmo probe, mesmo filtro de ACL aplicado dentro da busca - `search_chunks` em vez de `search_slim` - e o mesmo resultado: Carol vê o post-mortem restrito, Alice nunca vê, Dan não vê nada. A diferença é de custo, não de segurança: aqui cada candidato carrega seu texto completo dentro do índice de busca, então o ranking paga pelo documento inteiro em cada candidato, não só nos sobreviventes. Essa é a conta que o split fat/slim acima existe para evitar.

---

Expand All @@ -173,7 +193,7 @@ Crucialmente, **o provedor fake é o padrão**, e o backend Groq degrada para el

## Observando a saúde do roteador

Cada decisão de roteamento é registrada por um coletor de métricas (`infrastructure/metrics/`). Contadores em processo alimentam uma verificação rápida de degradação. Um backend Redis opcional persiste deltas bufferizados em um HASH com `HINCRBY`; um scheduler ou hook de encerramento chama `flush()` fora do caminho de requisição. O TTL remove o agregado após 24 horas sem escritas, e falhas do backend nunca quebram requisições.
Cada decisão de roteamento é registrada por um coletor de métricas (`infrastructure/metrics/`). Contadores em processo alimentam uma verificação rápida de degradação - se uma fatia grande demais das decisões cair na rota genérica, o roteador pode estar desviando e precisa de recompilação. Um backend Redis opcional persiste deltas bufferizados em um HASH com `HINCRBY`; um scheduler ou hook de encerramento chama `flush()` fora do caminho de requisição. O TTL remove o agregado após 24 horas sem escritas, e falhas do backend nunca quebram requisições.

---

Expand Down Expand Up @@ -217,15 +237,16 @@ make demo # demo end-to-end com script
make acl-demo # filtro de controle de acesso em isolamento
make structured-demo # consulta estruturada compilada para RediSearch
make fatslim-demo # divisão de recuperação fat/slim
make flat-demo # modelo flat de conhecimento, anterior ao split fat/slim
make test # suite de testes
make check # lint + typecheck + teste
make redis-up # iniciar Redis Stack
```

Convenções: todo o código em inglês, docstrings em todos os módulos/classes/funções públicas (estilo Google), type hints completos, `ruff` para lint e formatação. O harness de agente em [`CLAUDE.md`](./CLAUDE.md) documenta as regras de arquitetura e os guardrails que não devem regredir.

---

## Notas sobre o escopo

Este é um repositório de estudo/demo. Os provedores fake são léxicos, não semânticos; eles exercitam o encanamento de forma determinística, não a qualidade de um embedder real. Os thresholds do roteador são fornecidos com uma calibração separada para o backend fake (`domain/policies`), o que por si só é um ponto relevante: **thresholds são uma propriedade do modelo de embedding, então trocar de modelo significa recalibrar, não editar prompts.** O caminho DSPy + Groq é real (`dspy.Predict` + `dspy.Refine` com recompensa de fundamentação) e funciona assim que você instala o extra `groq` e define `GROQ_API_KEY`; sem eles o sistema cai no provedor fake para que o demo padrão sempre rode. Os provedores Azure estão scaffolded até o ponto em que a única peça faltando é a chamada SDK externa.

**Fontes do conteúdo.** A prosa em `data/catalog/` é texto original escrito para este demo fictício, informado por práticas de engenharia publicamente documentadas: os padrões de retry/backoff-com-jitter, disciplina de failover e o framing de SLO/error-budget se apoiam em ideias do livro *Site Reliability Engineering* do Google (gratuito em sre.google/books, CC BY-NC-ND 4.0) e sua cultura de post-mortem "blameless"; o rate limiting por token bucket se apoia na documentação pública de rate limiting do CNCF/Envoy/Kubernetes (CC BY 4.0); o padrão de idempotency-key se apoia em convenções publicamente documentadas usadas por grandes APIs de pagamento; a rotação de credenciais se apoia na orientação da OWASP sobre gestão de segredos. Nenhum texto é copiado dessas fontes - tudo aqui é paráfrase original escrita para este repositório. As URLs `wiki.internal/...` nos fixtures são placeholders fictícios para uma wiki interna inventada, não links para as fontes reais acima.
27 changes: 25 additions & 2 deletions src/meridian/application/router/semantic_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,21 @@ def build(self) -> None:
def route(self, query: str, *, k: int = 3) -> RouterResult:
"""Score every intent for ``query`` and return a ranked, judged result.

Ties (equal scores, which happen with near-duplicate catalog phrases or
degenerate zero vectors) are broken by the intent's own confidence
threshold, descending: the intent that demands more evidence to win
signals a more specific, better-calibrated match, so it takes
precedence over a laxer intent with the same raw score. Intents whose
thresholds also tie fall back to intent name, ascending, so the order
is always deterministic. This is an explicit sort key rather than an
accident of dict order: without it, tie-break behaviour would depend on
Python's sort stability plus whatever order intents happen to be built
in, which is exactly the kind of hidden coupling that breaks silently
on refactor. A tied ``best_intent`` still surfaces to the caller (trace
events, the disambiguation prompt in :class:`AskService`, and per-route
metrics), so the choice must be deterministic even though score ties
are almost always also flagged ambiguous by rule 3 below.

:param query: The user's natural-language message.
:param k: How many top candidates to include in ``topk``.
:returns: A :class:`RouterResult` with scores and an ambiguity verdict.
Expand All @@ -157,7 +172,7 @@ def route(self, query: str, *, k: int = 3) -> RouterResult:
)
for intent in self._intents
]
scored.sort(key=lambda s: s.score, reverse=True)
scored.sort(key=lambda s: (-s.score, -self._threshold_for(s.intent), s.intent))
topk = scored[: max(1, k)]

ambiguous, rule = self._detect_ambiguity(topk)
Expand All @@ -180,6 +195,14 @@ def route(self, query: str, *, k: int = 3) -> RouterResult:
)
return result

def _threshold_for(self, intent: str) -> float:
"""Return the calibrated confidence threshold for ``intent``.

Falls back to the config default for intents absent from the
per-intent threshold map.
"""
return self._thresholds.get(intent, self._config.default_intent_threshold)

def _detect_ambiguity(self, topk: list[ScoredIntent]) -> tuple[bool, str]:
"""Apply the three ambiguity rules in order.

Expand All @@ -188,7 +211,7 @@ def _detect_ambiguity(self, topk: list[ScoredIntent]) -> tuple[bool, str]:
is recorded in the trace for auditability.
"""
best = topk[0]
threshold = self._thresholds.get(best.intent, self._config.default_intent_threshold)
threshold = self._threshold_for(best.intent)
runner_up = topk[1] if len(topk) > 1 else None
gap = (best.score - runner_up.score) if runner_up else float("inf")

Expand Down
Loading
Loading