mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
Refuse to silently extract mock content into a real Space
A fresh Smriti install with no provider configured could still run `smriti checkpoint create --extract`, which silently returned MockAdapter content like "Mock decision from provider". If committed, that placeholder text became part of the user's real reasoning state. Root cause: backend/app/api/routes/checkpoint.py:417 called `get_adapter(cfg.background.provider, allow_mock=True)`. The `allow_mock=True` flag means the registry quietly returns MockAdapter when no API key is configured, rather than raising. The CLI received the canned mock JSON and committed it without inspecting whether it came from a real LLM. The extract endpoint was the only route in the codebase with this pattern — draft, review, chat title, and chat send already correctly pass `allow_mock=False`. The new contract: - Core Smriti (setup, doctor, quickstart, state/current/metrics, claims, attach, manual JSON checkpoints) requires no API key. - Real LLM-backed paths (`--extract`, draft, review, chat send) require a configured provider — OpenAI / Anthropic / OpenRouter / generic OpenAI-compatible (local models like Ollama). - Mock extraction still works for tests and demos, but only when the caller explicitly opts in (use_mock=true on the HTTP payload). It is never silently the default. Backend: - POST /api/v5/checkpoint/extract now passes allow_mock=False and catches ProviderNotConfiguredError, returning HTTP 412 with a structured detail: error code, human message, the provider it tried, and a list of fix paths the CLI surfaces. - CheckpointExtractResponse gains `provider` and `model` echo fields (additive, default empty) so callers can confirm what answered. CLI: - checkpoint create --extract catches 412 and prints the actionable fix list; exits 78 (EX_CONFIG). Defense in depth: even on a 200, refuses to commit if response.provider == "mock" on the default path (so any future regression in the backend is still caught). - smriti doctor surfaces background provider state prominently: `ready (real LLM extraction enabled)` or `⚠ MOCK or DISABLED — … will fail until a provider is configured`. - smriti doctor --strict exits 78 when the background provider is mock/disabled or the backend is unreachable. Safe to wire into CI before any --extract step. - On a successful --extract commit, the CLI shows `extracted via <provider>/<model>` under the commit confirmation. Docs: - README: new "Provider configuration (LLM-backed features)" section drawing the boundary explicitly; mentions the generic provider for local OpenAI-compatible models; flags mock as test-only. - .env.example: rewrote the provider section so an empty key or a model-without-a-key is not interpreted as "ready". - Skill pack template: new §4.1 "Before your first --extract: verify the provider" telling agents to run `smriti doctor`, refuse --extract when background_provider is mock/disabled, and fall back to manual JSON checkpoints or ask the human to configure a provider. Re-rendered to AGENTS.md (Codex target). The Claude Code target (.claude/skills/smriti/SKILL.md) is gitignored per-user install; rerun `smriti skills install claude-code` to refresh. - website/index.html: Try-it lede now spells out which features need a provider rather than gesturing at "optional LLM features". Tests: - test_extract_without_provider_fails_loud: regression for the bug — monkeypatches get_adapter to raise ProviderNotConfiguredError, asserts HTTP 412 with the structured detail shape, and asserts the response body contains neither "Mock decision from provider" nor "Mock Checkpoint". This test would fail on pre-fix code. - test_extract_with_provider_echoes_provider_and_model: pins the green path — provider and model must be echoed and must not be "mock" when the real adapter answers. - test_extract_happy_path_with_mock: unchanged, still pins the explicit use_mock=true contract. - Full backend integration suite: 165 passed locally (with the pre-existing real-provider draft test passing under backend/config/providers.yaml).
This commit is contained in:
parent
15e099b67e
commit
713ed9a007
10 changed files with 414 additions and 72 deletions
49
.env.example
49
.env.example
|
|
@ -25,28 +25,55 @@ SMRITI_DB_MODE=local
|
|||
# SMRITI_DB_MODE=postgres
|
||||
# DATABASE_URL=postgresql://smriti:smriti@localhost:5432/smriti
|
||||
|
||||
# Provider API keys — uncomment and set the ones you want to use.
|
||||
# If unset, the backend falls back to config/providers.yaml, then to
|
||||
# the mock provider (deterministic responses, no real LLM calls).
|
||||
# ────────────────────────────────────────────────────────────────────────
|
||||
# Provider configuration (LLM-backed features only)
|
||||
# ────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Core Smriti — setup, doctor, quickstart, state/current/metrics, claims,
|
||||
# attach, and hand-written checkpoints — works WITHOUT any API key.
|
||||
#
|
||||
# These features require a real configured provider:
|
||||
# - `smriti checkpoint create --extract`
|
||||
# - `smriti checkpoint review`
|
||||
# - checkpoint draft
|
||||
# - chat UI send loop
|
||||
#
|
||||
# Without a provider, those paths refuse to run (HTTP 412) rather than
|
||||
# silently returning placeholder MockAdapter content. Mock content is for
|
||||
# tests and demos only; it must never be committed into a real project.
|
||||
#
|
||||
# Uncomment ONE of the keys below for a hosted provider, OR configure the
|
||||
# generic provider (further down) for a local OpenAI-compatible model.
|
||||
#
|
||||
# IMPORTANT: do not leave a key set to empty (e.g. OPENAI_API_KEY=).
|
||||
# An empty value is actively placed into os.environ by dotenv and can
|
||||
# mask the yaml fallback in some configurations. Either set a real
|
||||
# key or leave the line commented out.
|
||||
# mask the yaml fallback. Either set a real key or leave the line commented.
|
||||
# Setting OPENAI_MODEL alone (below) without OPENAI_API_KEY is NOT enough —
|
||||
# the model needs an actual provider with credentials behind it.
|
||||
#
|
||||
# OPENAI_API_KEY=your-openai-key-here
|
||||
# ANTHROPIC_API_KEY=your-anthropic-key-here
|
||||
# OPENROUTER_API_KEY=your-openrouter-key-here
|
||||
|
||||
# Default model for background intelligence (extraction, draft, review)
|
||||
# Default model for background intelligence (extraction, draft, review).
|
||||
# Only takes effect when a provider key (above) is set or the generic
|
||||
# provider (below) is configured.
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# Generic OpenAI-compatible provider (Ollama, LM Studio, vLLM, Together, etc.)
|
||||
# Set the API URL to use a local or cheap model instead of OpenAI/Anthropic.
|
||||
# API key is optional — local servers like Ollama don't require one.
|
||||
# Then set background_intelligence.provider to "generic" in providers.yaml.
|
||||
# Generic OpenAI-compatible provider — Ollama, LM Studio, vLLM, Together, etc.
|
||||
# Use this slot for a LOCAL model (or any OpenAI-API-compatible endpoint).
|
||||
# API key may be optional for local servers; OpenAI SDK accepts any non-empty
|
||||
# string. Then set background_intelligence.provider to "generic" in
|
||||
# backend/config/providers.yaml.
|
||||
#
|
||||
# Extraction is usually a lightweight structured-output task and is often
|
||||
# handled well by cost-efficient or local models, but quality varies — try
|
||||
# `smriti checkpoint create --extract --dry-run` on a representative
|
||||
# document before relying on a small model for real work.
|
||||
#
|
||||
# SMRITI_GENERIC_API_URL=http://localhost:11434/v1
|
||||
# SMRITI_GENERIC_MODEL=llama3.1:8b
|
||||
# SMRITI_GENERIC_API_KEY=
|
||||
# SMRITI_GENERIC_API_KEY=not-required
|
||||
|
||||
# App
|
||||
DEBUG=false
|
||||
|
|
|
|||
36
AGENTS.md
36
AGENTS.md
|
|
@ -582,6 +582,40 @@ know about. Concretely:
|
|||
- **You are about to explore an alternative direction and want to
|
||||
preserve the current line.** Fork first, then checkpoint on the fork.
|
||||
|
||||
### 4.1 Before your first `--extract`: verify the provider
|
||||
|
||||
The extract path uses Smriti's background LLM to turn freeform markdown
|
||||
into structured checkpoint fields. It **requires a real configured
|
||||
provider** (OpenAI / Anthropic / OpenRouter / generic OpenAI-compatible
|
||||
endpoint). If no provider is configured, the extract endpoint returns
|
||||
**HTTP 412 Precondition Failed** rather than silently falling back to
|
||||
mock content — mock content committed into a real Space pollutes the
|
||||
reasoning state Smriti exists to keep trustworthy.
|
||||
|
||||
Run `smriti doctor` once at the start of a session that intends to use
|
||||
`--extract`, and read the `background provider:` line.
|
||||
|
||||
```
|
||||
smriti doctor
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- **`background provider: ready (real LLM extraction enabled)`** —
|
||||
good, use `--extract` freely.
|
||||
- **`background provider: ⚠ MOCK or DISABLED — ... will fail until a
|
||||
provider is configured`** — do **not** use `--extract`. Either ask
|
||||
the human to configure a provider (OpenAI/Anthropic/OpenRouter key,
|
||||
or a local OpenAI-compatible endpoint via the generic provider), or
|
||||
write the checkpoint **manually as JSON** (see 4.1 below). Do not
|
||||
attempt to commit mock content as if it were real reasoning state.
|
||||
|
||||
For scripted/CI use, `smriti doctor --strict` exits non-zero
|
||||
(EX_CONFIG / 78) when the background provider is mock/disabled —
|
||||
useful as a pre-flight gate before any `--extract` step.
|
||||
|
||||
### 4.2 The extract path (provider required)
|
||||
|
||||
Use the **extract path**, not hand-written JSON. Pass freeform markdown
|
||||
describing the inflection point and Smriti's background LLM will pull
|
||||
out the structured fields (message, objective, summary, decisions,
|
||||
|
|
@ -611,7 +645,7 @@ Always tag `author_agent` with a stable identifier for your agent
|
|||
agents know who wrote what on the shared timeline. Inconsistent or
|
||||
missing `author_agent` makes divergence unattributable.
|
||||
|
||||
### 4.1 Checkpoint notes: annotating without checkpointing
|
||||
### 4.3 Checkpoint notes: annotating without checkpointing
|
||||
|
||||
Sometimes you need to add context to an existing checkpoint without
|
||||
creating a new one. Checkpoint notes are additive annotations —
|
||||
|
|
|
|||
51
README.md
51
README.md
|
|
@ -91,7 +91,7 @@ Backend reachable, CLI/backend versions aligned, provider status. If anything's
|
|||
smriti quickstart
|
||||
```
|
||||
|
||||
Seeds a `smriti-demo` Space — one finished mini-project (a rate-limiting feature built by two agents, with a branch explored and dropped) — and prints a ~3-minute guided walkthrough. Works without API keys (mock-mode extraction). Clean up with `smriti quickstart --remove`.
|
||||
Seeds a `smriti-demo` Space — one finished mini-project (a rate-limiting feature built by two agents, with a branch explored and dropped) — and prints a ~3-minute guided walkthrough. Works without API keys — quickstart seeds pre-built checkpoints, no live extraction. (For live `--extract` you need a real provider; see [Provider configuration](#provider-configuration-llm-backed-features).) Clean up with `smriti quickstart --remove`.
|
||||
|
||||
### 4. Attach your own project — `smriti init`
|
||||
|
||||
|
|
@ -211,27 +211,56 @@ The primitives that turn "shared state" from a phrase into something that actual
|
|||
|
||||
---
|
||||
|
||||
## API keys (optional)
|
||||
## Provider configuration (LLM-backed features)
|
||||
|
||||
**The core coordination loop runs without API keys.** Setup, `smriti doctor`, `smriti quickstart`, `smriti state` / `current` / `metrics`, claims, attachments, repo-state drift detection, hand-written checkpoints, and the chat UI's read-only dashboards (timeline, checkpoints, claims, drift signals) all work with no key.
|
||||
Smriti draws a hard line between **core coordination** (works with no API key) and **LLM-backed features** (require a configured provider).
|
||||
|
||||
API keys are only needed for the LLM-assisted features:
|
||||
### Works with no key
|
||||
|
||||
- `smriti checkpoint create --extract` — extract structured fields from freeform markdown
|
||||
- Checkpoint draft and consistency review (`smriti checkpoint review`)
|
||||
- The chat UI's send loop — where the agent actually responds to your messages
|
||||
- `setup` / `doctor` / `quickstart`
|
||||
- `smriti state` / `current` / `metrics`
|
||||
- claims (`smriti claim`, claim listing)
|
||||
- attach / no-arg project workflow
|
||||
- manual structured checkpoints (`smriti checkpoint create <space>` with a JSON payload on stdin)
|
||||
- repo-state drift detection
|
||||
- the chat UI's read-only dashboards (timeline, checkpoints, claims, drift signals)
|
||||
|
||||
Smriti supports OpenAI, Anthropic, OpenRouter, and any OpenAI-compatible provider (Ollama, LM Studio, vLLM) via the generic provider slot. Set keys in `.env` when you want those features for real:
|
||||
### Require a real provider
|
||||
|
||||
- `smriti checkpoint create --extract` — extracts structured fields from freeform markdown
|
||||
- `smriti checkpoint review` — consistency review of a checkpoint
|
||||
- checkpoint draft
|
||||
- the chat UI's send loop — where the agent actually responds
|
||||
|
||||
Without a configured provider, these refuse to run rather than silently return placeholder content. `--extract` returns **HTTP 412 Precondition Failed** with an actionable error listing the configuration paths. **Mock content is never silently committed into a real project** — that would pollute reasoning state, which Smriti exists to keep trustworthy.
|
||||
|
||||
### Configure a provider
|
||||
|
||||
Set one of these in `.env` (or your shell):
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=...
|
||||
ANTHROPIC_API_KEY=...
|
||||
OPENROUTER_API_KEY=...
|
||||
SMRITI_GENERIC_API_URL=http://localhost:11434/v1 # for Ollama / LM Studio / vLLM
|
||||
SMRITI_GENERIC_MODEL=llama3.1:8b
|
||||
```
|
||||
|
||||
Without a key set, the LLM-assisted paths return deterministic placeholder content so the mechanics still work — handy for trying the coordination loop end-to-end. Add a key when you want real extraction.
|
||||
Or, for a **local OpenAI-compatible model** (Ollama, LM Studio, vLLM, Together, etc.):
|
||||
|
||||
```
|
||||
SMRITI_GENERIC_API_URL=http://localhost:11434/v1 # your local server
|
||||
SMRITI_GENERIC_MODEL=llama3.1:8b
|
||||
# SMRITI_GENERIC_API_KEY=not-required # most local servers don't need one
|
||||
```
|
||||
|
||||
Then set `background_intelligence.provider: generic` in `backend/config/providers.yaml`.
|
||||
|
||||
Extraction is a relatively lightweight structured-output task and is usually handled well by cost-efficient or local OpenAI-compatible models. That said, *quality varies by model* — validate with `smriti checkpoint create --extract --dry-run` on a representative document before relying on a small model for real work.
|
||||
|
||||
After configuring, run `smriti doctor` (optionally with `--strict`) to confirm the background provider line reads `ready`. `--strict` exits non-zero (EX_CONFIG / 78) if the provider is `mock_or_disabled`, which makes it safe to use in CI before any `--extract` step.
|
||||
|
||||
### Mock mode
|
||||
|
||||
A deterministic `MockAdapter` exists for tests, demos, and the `quickstart` mechanics. It returns a fixed JSON blob (containing literal strings like `"Mock decision from provider"`). **It is never the default for `--extract`** — it only runs when the caller explicitly opts in (e.g. tests passing `use_mock=true` to the HTTP endpoint). Do not commit mock-extracted content into a real project Space.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from app.schemas import (
|
|||
ReviewIssue,
|
||||
)
|
||||
from app.providers.registry import get_adapter, get_mock_adapter
|
||||
from app.config_loader import get_config
|
||||
from app.config_loader import get_config, ProviderNotConfiguredError
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -403,23 +403,57 @@ Rules:
|
|||
- Output ONLY valid JSON. No markdown wrappers, no explanation.
|
||||
"""
|
||||
|
||||
try:
|
||||
if request.use_mock:
|
||||
adapter = get_mock_adapter()
|
||||
bg_model = "mock"
|
||||
else:
|
||||
cfg = get_config()
|
||||
bg_model = cfg.background.model
|
||||
# allow_mock=True so an unconfigured test env falls back to
|
||||
# MockAdapter (which supports JSON-mode responses); production
|
||||
# envs always have a real provider configured and go through
|
||||
# the real adapter path.
|
||||
adapter = get_adapter(cfg.background.provider, allow_mock=True)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Background provider not configured. Error: {e}",
|
||||
)
|
||||
# Resolve the adapter.
|
||||
#
|
||||
# Contract (post launch-blocker fix):
|
||||
# - use_mock=True (explicit opt-in): always return MockAdapter. Used by
|
||||
# tests and demos that need deterministic JSON without a real provider.
|
||||
# - use_mock=False (default): require a real configured provider. If none
|
||||
# is configured, return 412 Precondition Failed with a structured
|
||||
# detail explaining how to fix it. Never silently fall back to mock —
|
||||
# mock content committed into a real project pollutes reasoning state.
|
||||
if request.use_mock:
|
||||
adapter = get_mock_adapter()
|
||||
bg_provider = "mock"
|
||||
bg_model = "mock"
|
||||
else:
|
||||
cfg = get_config()
|
||||
bg_provider = cfg.background.provider
|
||||
bg_model = cfg.background.model
|
||||
try:
|
||||
adapter = get_adapter(bg_provider, allow_mock=False)
|
||||
except ProviderNotConfiguredError as e:
|
||||
logger.warning(
|
||||
"extract refused: background provider '%s' not configured (%s)",
|
||||
bg_provider, e,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail={
|
||||
"error": "provider_not_configured",
|
||||
"message": (
|
||||
f"Background LLM provider '{bg_provider}' is not configured. "
|
||||
"Real checkpoint extraction requires a real provider — refusing "
|
||||
"to silently fall back to mock content."
|
||||
),
|
||||
"provider": bg_provider,
|
||||
"model": bg_model,
|
||||
"fix": [
|
||||
"Set OPENAI_API_KEY in .env (or your shell) for OpenAI.",
|
||||
"Set ANTHROPIC_API_KEY for Anthropic.",
|
||||
"Set OPENROUTER_API_KEY for OpenRouter.",
|
||||
"For a local OpenAI-compatible model (Ollama, LM Studio, vLLM): "
|
||||
"set SMRITI_GENERIC_API_URL + SMRITI_GENERIC_MODEL, then set "
|
||||
"background_intelligence.provider to 'generic' in "
|
||||
"backend/config/providers.yaml.",
|
||||
"Or skip extraction and create a checkpoint manually with "
|
||||
"`smriti checkpoint create <space>` and a JSON payload on stdin.",
|
||||
"For tests/demos only: pass use_mock=true to explicitly request "
|
||||
"the MockAdapter (do NOT commit that output into a real project).",
|
||||
],
|
||||
"details": str(e),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
raw_response = adapter.send(
|
||||
|
|
@ -441,6 +475,8 @@ Rules:
|
|||
open_questions=_dedup(data.get("open_questions", [])),
|
||||
entities=_dedup(data.get("entities", [])),
|
||||
artifacts=[a for a in data.get("artifacts", []) if isinstance(a, dict)],
|
||||
provider=bg_provider,
|
||||
model=bg_model,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Extract LLM returned invalid JSON: {raw_response}")
|
||||
|
|
|
|||
|
|
@ -158,3 +158,9 @@ class CheckpointExtractResponse(BaseModel):
|
|||
open_questions: list[str] = Field(default_factory=list)
|
||||
entities: list[str] = Field(default_factory=list)
|
||||
artifacts: list[dict] = Field(default_factory=list)
|
||||
# Echo the provider/model that actually answered, so callers can confirm
|
||||
# whether the extraction came from a real LLM or the mock adapter. The
|
||||
# CLI surfaces this on every --extract commit and uses it to refuse to
|
||||
# persist mock content into a real project.
|
||||
provider: str = Field("", description="Provider that produced this extraction (e.g. 'openai', 'anthropic', 'mock').")
|
||||
model: str = Field("", description="Model identifier that produced this extraction (or 'mock').")
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
"""Integration tests for POST /api/v5/checkpoint/extract.
|
||||
|
||||
Tests use MockAdapter's JSON-mode path (registry._MOCK_JSON_RESPONSE),
|
||||
which returns a deterministic canned blob when the caller passes
|
||||
response_format={"type": "json_object"}. The extract endpoint always
|
||||
requests JSON mode, so every test hits the canned response.
|
||||
Post launch-blocker fix (provider-extract-safety):
|
||||
|
||||
- The DEFAULT path (no `use_mock` flag) requires a real configured
|
||||
background provider. If none is configured, the endpoint returns
|
||||
HTTP 412 with a structured `provider_not_configured` detail. It
|
||||
never silently falls back to MockAdapter — that would pollute a
|
||||
real user's reasoning state with placeholder content.
|
||||
|
||||
- The EXPLICIT mock path (`use_mock=True`) still works for tests
|
||||
and demos. It always returns the canned MockAdapter response.
|
||||
|
||||
These tests pin both contracts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from app.config_loader import ProviderNotConfiguredError
|
||||
from app.providers.base import ProviderAdapter
|
||||
from app.providers import registry as provider_registry
|
||||
from app.api.routes import checkpoint as checkpoint_route
|
||||
|
||||
|
||||
def _sample_markdown() -> str:
|
||||
return """# Design: envdiff CLI
|
||||
|
|
@ -27,8 +43,11 @@ def main():
|
|||
"""
|
||||
|
||||
|
||||
# ── Explicit mock path: use_mock=True ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_extract_happy_path_with_mock(client):
|
||||
"""Extract endpoint returns canned mock fields when use_mock=True."""
|
||||
"""use_mock=True: MockAdapter returns canned content, 200 OK."""
|
||||
r = client.post(
|
||||
"/api/v5/checkpoint/extract",
|
||||
json={"content": _sample_markdown(), "use_mock": True},
|
||||
|
|
@ -51,36 +70,99 @@ def test_extract_happy_path_with_mock(client):
|
|||
assert "MockEntity" in data["entities"]
|
||||
assert len(data["artifacts"]) == 1
|
||||
assert data["artifacts"][0]["label"] == "Mock artifact"
|
||||
# Echo fields must mark this as mock so callers can refuse to commit.
|
||||
assert data["provider"] == "mock"
|
||||
assert data["model"] == "mock"
|
||||
|
||||
|
||||
def test_extract_default_path_returns_shape(client):
|
||||
"""Default path (no use_mock flag) returns a valid CheckpointExtractResponse
|
||||
shape regardless of which provider answers. In a test env with no API
|
||||
keys this hits MockAdapter via the allow_mock=True fallback; in a dev
|
||||
env with real keys it hits the real provider and returns real extracted
|
||||
fields. Either way the response must be well-shaped.
|
||||
# ── Default path without provider: must fail loud (the launch-blocker fix) ───
|
||||
|
||||
|
||||
def test_extract_without_provider_fails_loud(client, monkeypatch):
|
||||
"""Default path (no use_mock) + no real provider configured: HTTP 412 with
|
||||
a structured 'provider_not_configured' detail. Never silent mock.
|
||||
|
||||
This is the regression test for the launch-blocker bug where the extract
|
||||
endpoint silently returned MockAdapter content like
|
||||
"Mock decision from provider" when no provider was configured.
|
||||
"""
|
||||
# Force the provider-not-configured state regardless of test-env API keys.
|
||||
def _raise_not_configured(provider: str, allow_mock: bool = False):
|
||||
# The endpoint must call with allow_mock=False (no silent fallback).
|
||||
assert allow_mock is False, (
|
||||
"extract endpoint must call get_adapter with allow_mock=False"
|
||||
)
|
||||
raise ProviderNotConfiguredError(f"no API key for {provider}")
|
||||
|
||||
monkeypatch.setattr(checkpoint_route, "get_adapter", _raise_not_configured)
|
||||
|
||||
r = client.post(
|
||||
"/api/v5/checkpoint/extract",
|
||||
json={"content": _sample_markdown()},
|
||||
)
|
||||
assert r.status_code == 412, r.text
|
||||
detail = r.json().get("detail")
|
||||
assert isinstance(detail, dict), f"detail must be a structured dict, got {type(detail)}"
|
||||
assert detail.get("error") == "provider_not_configured"
|
||||
assert "not configured" in detail.get("message", "").lower()
|
||||
assert detail.get("provider") # backend echoes which provider it tried
|
||||
assert isinstance(detail.get("fix"), list)
|
||||
assert len(detail["fix"]) >= 3, "fix list should enumerate at least 3 paths"
|
||||
# CRITICAL: response body must NOT contain mock content. The bug we are
|
||||
# fixing literally returned "Mock decision from provider" in the response.
|
||||
body_text = r.text
|
||||
assert "Mock decision from provider" not in body_text
|
||||
assert "Mock Checkpoint" not in body_text
|
||||
|
||||
|
||||
# ── Default path with a real provider: provider/model echoed ─────────────────
|
||||
|
||||
|
||||
class _FakeAdapter(ProviderAdapter):
|
||||
"""Test double for a real provider — returns valid JSON shaped like a
|
||||
real extraction so we can verify the endpoint's success-path metadata."""
|
||||
|
||||
def send(self, messages, model, **kwargs):
|
||||
return json.dumps({
|
||||
"title": "Real Extraction Result",
|
||||
"objective": "Build envdiff CLI.",
|
||||
"summary": "A short summary of the design doc.",
|
||||
"decisions": ["Use argparse, not click"],
|
||||
"assumptions": ["Python 3.11+ is available"],
|
||||
"tasks": [{"id": "impl-1", "text": "Scaffold the CLI"}],
|
||||
"open_questions": [],
|
||||
"entities": ["envdiff"],
|
||||
"artifacts": [],
|
||||
})
|
||||
|
||||
def healthcheck(self) -> bool: # pragma: no cover
|
||||
return True
|
||||
|
||||
|
||||
def test_extract_with_provider_echoes_provider_and_model(client, monkeypatch):
|
||||
"""Default path + a real (faked) provider: 200 OK, response echoes
|
||||
`provider` and `model` so the CLI can show 'extracted via openai/gpt-4o-mini'
|
||||
on commit and refuse to persist mock content."""
|
||||
def _fake_adapter(provider: str, allow_mock: bool = False):
|
||||
return _FakeAdapter()
|
||||
|
||||
monkeypatch.setattr(checkpoint_route, "get_adapter", _fake_adapter)
|
||||
|
||||
r = client.post(
|
||||
"/api/v5/checkpoint/extract",
|
||||
json={"content": _sample_markdown()},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
# Response shape: every field present, strings are strings, lists are lists.
|
||||
assert isinstance(data["title"], str)
|
||||
assert isinstance(data["objective"], str)
|
||||
assert isinstance(data["summary"], str)
|
||||
assert isinstance(data["decisions"], list)
|
||||
assert isinstance(data["assumptions"], list)
|
||||
assert isinstance(data["tasks"], list)
|
||||
assert isinstance(data["open_questions"], list)
|
||||
assert isinstance(data["entities"], list)
|
||||
assert isinstance(data["artifacts"], list)
|
||||
# Something must have been extracted from the sample — the title must be
|
||||
# non-empty and at least one decision should appear since the sample has
|
||||
# a "## Decisions" section with two bullet points.
|
||||
assert data["title"].strip() != ""
|
||||
assert len(data["decisions"]) > 0
|
||||
assert data["title"] == "Real Extraction Result"
|
||||
# Echo: must be the configured provider/model, NOT "mock".
|
||||
assert data["provider"] != "mock", "real-provider path must not echo provider=mock"
|
||||
assert data["provider"] != ""
|
||||
assert data["model"] != "mock"
|
||||
assert data["model"] != ""
|
||||
|
||||
|
||||
# ── Validation tests (unchanged) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_extract_rejects_empty_content(client):
|
||||
|
|
|
|||
|
|
@ -953,7 +953,16 @@ def format_doctor(report: dict) -> str:
|
|||
else:
|
||||
parts.append("- missing capabilities: none")
|
||||
parts.append(f"- CLI path: {checks.get('cli_path') or 'unknown'}")
|
||||
parts.append(f"- background provider: {checks.get('background_provider') or 'unknown'}")
|
||||
bg = checks.get("background_provider") or "unknown"
|
||||
if bg == "ready":
|
||||
parts.append("- background provider: ready (real LLM extraction enabled)")
|
||||
elif bg == "mock_or_disabled":
|
||||
parts.append(
|
||||
"- background provider: ⚠ MOCK or DISABLED — `smriti checkpoint create "
|
||||
"--extract`, draft, and review will fail until a provider is configured"
|
||||
)
|
||||
else:
|
||||
parts.append(f"- background provider: {bg}")
|
||||
parts.append("")
|
||||
|
||||
parts.append("## Capabilities")
|
||||
|
|
|
|||
|
|
@ -87,6 +87,29 @@ def _fail(message: str, code: int = 1) -> None:
|
|||
sys.exit(code)
|
||||
|
||||
|
||||
def _fail_provider_not_configured(detail: dict, code: int = 78) -> None:
|
||||
"""Render the backend's structured 'provider_not_configured' error as a
|
||||
clear, actionable message and exit non-zero.
|
||||
|
||||
Exit code 78 = EX_CONFIG (configuration error) so scripts can distinguish
|
||||
a missing-provider failure from a generic CLI error.
|
||||
"""
|
||||
provider = detail.get("provider") or "background"
|
||||
msg = detail.get("message") or (
|
||||
f"Background LLM provider '{provider}' is not configured."
|
||||
)
|
||||
fix = detail.get("fix") or []
|
||||
lines: list[str] = []
|
||||
lines.append("error: " + msg)
|
||||
lines.append("")
|
||||
lines.append("To fix this, do one of:")
|
||||
for item in fix:
|
||||
lines.append(f" - {item}")
|
||||
lines.append("")
|
||||
lines.append("Run `smriti doctor` to confirm provider status after configuring.")
|
||||
_fail("\n".join(lines), code=code)
|
||||
|
||||
|
||||
def _confirm(preview: str, yes_flag: bool) -> bool:
|
||||
"""Interactive 'Type yes' if stdin is a TTY, otherwise require --yes.
|
||||
|
||||
|
|
@ -799,13 +822,42 @@ def _read_checkpoint_json(args: argparse.Namespace) -> dict:
|
|||
|
||||
|
||||
def cmd_doctor(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||
"""Print narrow backend/runtime diagnostics."""
|
||||
"""Print narrow backend/runtime diagnostics.
|
||||
|
||||
With --strict, exits non-zero if any critical check fails. The current
|
||||
strict checks are:
|
||||
- backend reachable
|
||||
- background provider configured for real LLM extraction (--extract,
|
||||
draft, review). When this fails strict, agents and CI should refuse
|
||||
to use --extract; manual JSON checkpointing still works.
|
||||
"""
|
||||
report = _build_doctor_report(client)
|
||||
if args.json:
|
||||
_print_json(report)
|
||||
else:
|
||||
print(format_doctor(report), end="")
|
||||
|
||||
if getattr(args, "strict", False):
|
||||
failures: list[str] = []
|
||||
backend = report.get("backend") or {}
|
||||
if not backend.get("reachable"):
|
||||
failures.append("backend is not reachable")
|
||||
checks = report.get("checks") or {}
|
||||
bg = checks.get("background_provider")
|
||||
if bg == "mock_or_disabled":
|
||||
failures.append(
|
||||
"background provider is mock_or_disabled — `smriti checkpoint create "
|
||||
"--extract`, draft, and review will fail. Configure a provider."
|
||||
)
|
||||
elif bg == "unknown":
|
||||
failures.append("background provider status is unknown (backend reachable?).")
|
||||
if failures:
|
||||
print("", file=sys.stderr)
|
||||
print("strict mode: " + str(len(failures)) + " check(s) failed:", file=sys.stderr)
|
||||
for f in failures:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
sys.exit(78) # EX_CONFIG
|
||||
|
||||
|
||||
def cmd_space_list(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||
spaces = client.list_spaces()
|
||||
|
|
@ -1169,7 +1221,24 @@ def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> Non
|
|||
# and use the returned fields as the commit payload. No hand-written
|
||||
# JSON required.
|
||||
content = _read_raw_content()
|
||||
extracted = client.extract_checkpoint_content(content)
|
||||
try:
|
||||
extracted = client.extract_checkpoint_content(content)
|
||||
except SmritiError as e:
|
||||
# Provider not configured: backend returns 412 with a structured
|
||||
# detail. Surface the actionable fix list and exit non-zero — do
|
||||
# NOT fall back to mock or hand-written content silently.
|
||||
if e.status == 412 and isinstance(e.detail, dict) and e.detail.get("error") == "provider_not_configured":
|
||||
_fail_provider_not_configured(e.detail)
|
||||
raise
|
||||
# Defense in depth: if the backend somehow returned mock content on
|
||||
# the default path (use_mock=False), refuse to commit it. Mock
|
||||
# output committed into a real project pollutes reasoning state.
|
||||
if extracted.get("provider") == "mock":
|
||||
_fail(
|
||||
"error: extract returned mock content (provider=mock) on the default path.\n"
|
||||
"Refusing to commit. This is a backend bug — please report it.\n"
|
||||
"If you intentionally want mock content (tests/demos), build the payload manually.",
|
||||
)
|
||||
# Extractor returns `title`; checkpoints store `message`. Map it.
|
||||
# If the LLM returned an empty title, fall back to a generic label
|
||||
# so the required `message` field is always populated.
|
||||
|
|
@ -1184,12 +1253,17 @@ def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> Non
|
|||
"entities": extracted.get("entities", []),
|
||||
"artifacts": extracted.get("artifacts", []),
|
||||
}
|
||||
# Stash provider/model so we can show them on commit confirmation.
|
||||
_extract_provider = extracted.get("provider") or ""
|
||||
_extract_model = extracted.get("model") or ""
|
||||
else:
|
||||
payload = _read_checkpoint_json(args)
|
||||
if not isinstance(payload, dict):
|
||||
_fail("Checkpoint JSON must be an object, got: " + type(payload).__name__)
|
||||
if not payload.get("message"):
|
||||
_fail("Checkpoint JSON must include a 'message' field.")
|
||||
_extract_provider = ""
|
||||
_extract_model = ""
|
||||
|
||||
if args.dry_run:
|
||||
# Print the full payload (extracted or hand-written) as JSON and
|
||||
|
|
@ -1260,6 +1334,8 @@ def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> Non
|
|||
else:
|
||||
h = commit.get("commit_hash", "")
|
||||
print(f"Created checkpoint: `{h[:7]}` {commit.get('message', '')}")
|
||||
if _extract_provider:
|
||||
print(f" extracted via {_extract_provider}/{_extract_model}")
|
||||
|
||||
|
||||
def cmd_checkpoint_show(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||
|
|
@ -2124,6 +2200,15 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
help="Diagnose backend reachability and runtime/code freshness",
|
||||
)
|
||||
doctor_parser.add_argument("--json", action="store_true", help="Output structured JSON")
|
||||
doctor_parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Exit non-zero (EX_CONFIG / 78) when any critical check fails — "
|
||||
"currently: backend unreachable, or background provider mock_or_disabled "
|
||||
"(blocks `checkpoint create --extract`, draft, review)."
|
||||
),
|
||||
)
|
||||
doctor_parser.set_defaults(func=cmd_doctor)
|
||||
|
||||
# quickstart — seed a curated demo space so the product clicks fast
|
||||
|
|
|
|||
|
|
@ -585,6 +585,40 @@ know about. Concretely:
|
|||
- **You are about to explore an alternative direction and want to
|
||||
preserve the current line.** Fork first, then checkpoint on the fork.
|
||||
|
||||
### 4.1 Before your first `--extract`: verify the provider
|
||||
|
||||
The extract path uses Smriti's background LLM to turn freeform markdown
|
||||
into structured checkpoint fields. It **requires a real configured
|
||||
provider** (OpenAI / Anthropic / OpenRouter / generic OpenAI-compatible
|
||||
endpoint). If no provider is configured, the extract endpoint returns
|
||||
**HTTP 412 Precondition Failed** rather than silently falling back to
|
||||
mock content — mock content committed into a real Space pollutes the
|
||||
reasoning state Smriti exists to keep trustworthy.
|
||||
|
||||
Run `smriti doctor` once at the start of a session that intends to use
|
||||
`--extract`, and read the `background provider:` line.
|
||||
|
||||
```
|
||||
{{mcp:Bash: `smriti doctor` (or ask the human to run it — `doctor` is CLI-only, no MCP tool).}}{{cli:smriti doctor}}
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- **`background provider: ready (real LLM extraction enabled)`** —
|
||||
good, use `--extract` freely.
|
||||
- **`background provider: ⚠ MOCK or DISABLED — ... will fail until a
|
||||
provider is configured`** — do **not** use `--extract`. Either ask
|
||||
the human to configure a provider (OpenAI/Anthropic/OpenRouter key,
|
||||
or a local OpenAI-compatible endpoint via the generic provider), or
|
||||
write the checkpoint **manually as JSON** (see 4.1 below). Do not
|
||||
attempt to commit mock content as if it were real reasoning state.
|
||||
|
||||
For scripted/CI use, `smriti doctor --strict` exits non-zero
|
||||
(EX_CONFIG / 78) when the background provider is mock/disabled —
|
||||
useful as a pre-flight gate before any `--extract` step.
|
||||
|
||||
### 4.2 The extract path (provider required)
|
||||
|
||||
Use the **extract path**, not hand-written JSON. Pass freeform markdown
|
||||
describing the inflection point and Smriti's background LLM will pull
|
||||
out the structured fields (message, objective, summary, decisions,
|
||||
|
|
@ -630,7 +664,7 @@ Always tag `author_agent` with a stable identifier for your agent
|
|||
agents know who wrote what on the shared timeline. Inconsistent or
|
||||
missing `author_agent` makes divergence unattributable.
|
||||
|
||||
### 4.1 Checkpoint notes: annotating without checkpointing
|
||||
### 4.3 Checkpoint notes: annotating without checkpointing
|
||||
|
||||
Sometimes you need to add context to an existing checkpoint without
|
||||
creating a new one. Checkpoint notes are additive annotations —
|
||||
|
|
|
|||
|
|
@ -579,7 +579,7 @@
|
|||
<div class="container">
|
||||
<p class="section-eyebrow">Try it</p>
|
||||
<h2>Up and running in <span class="accent">minutes</span>.</h2>
|
||||
<p class="lede"><strong>No Docker, no API keys, no cloud required.</strong> The core coordination loop runs on a local SQLite file. API keys come in only for optional LLM-assisted features.</p>
|
||||
<p class="lede"><strong>No Docker, no API keys, no cloud required</strong> for the core coordination loop — setup, doctor, quickstart, state/current/metrics, claims, attach, and hand-written checkpoints all run on a local SQLite file. <strong>LLM-backed features</strong> — <code>checkpoint create --extract</code>, draft, review, the chat send loop — need a real provider (OpenAI / Anthropic / OpenRouter / local OpenAI-compatible). They refuse to run on the default mock adapter rather than silently committing placeholder content.</p>
|
||||
|
||||
<div class="try-stack">
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue