kiwime-compiler 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. kiwime_compiler-0.1.0/.gitignore +78 -0
  2. kiwime_compiler-0.1.0/PKG-INFO +19 -0
  3. kiwime_compiler-0.1.0/README.md +92 -0
  4. kiwime_compiler-0.1.0/pyproject.toml +46 -0
  5. kiwime_compiler-0.1.0/src/kiwime_compiler/__init__.py +8 -0
  6. kiwime_compiler-0.1.0/src/kiwime_compiler/__main__.py +57 -0
  7. kiwime_compiler-0.1.0/src/kiwime_compiler/context.py +30 -0
  8. kiwime_compiler-0.1.0/src/kiwime_compiler/ids.py +43 -0
  9. kiwime_compiler-0.1.0/src/kiwime_compiler/pipeline.py +91 -0
  10. kiwime_compiler-0.1.0/src/kiwime_compiler/providers/__init__.py +1 -0
  11. kiwime_compiler-0.1.0/src/kiwime_compiler/providers/anthropic.py +189 -0
  12. kiwime_compiler-0.1.0/src/kiwime_compiler/providers/base.py +75 -0
  13. kiwime_compiler-0.1.0/src/kiwime_compiler/providers/bedrock.py +174 -0
  14. kiwime_compiler-0.1.0/src/kiwime_compiler/providers/ollama.py +72 -0
  15. kiwime_compiler-0.1.0/src/kiwime_compiler/providers/ollama_generator.py +159 -0
  16. kiwime_compiler-0.1.0/src/kiwime_compiler/providers/openai_compat.py +221 -0
  17. kiwime_compiler-0.1.0/src/kiwime_compiler/providers/registry.py +219 -0
  18. kiwime_compiler-0.1.0/src/kiwime_compiler/py.typed +0 -0
  19. kiwime_compiler-0.1.0/src/kiwime_compiler/server.py +423 -0
  20. kiwime_compiler-0.1.0/src/kiwime_compiler/store_client.py +593 -0
  21. kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/__init__.py +6 -0
  22. kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/_common.py +63 -0
  23. kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/capability.py +114 -0
  24. kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/case.py +114 -0
  25. kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/episodic.py +94 -0
  26. kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/graph.py +124 -0
  27. kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/identity.py +146 -0
  28. kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/semantic.py +98 -0
  29. kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/topic.py +86 -0
  30. kiwime_compiler-0.1.0/tests/__init__.py +0 -0
  31. kiwime_compiler-0.1.0/tests/conftest.py +13 -0
  32. kiwime_compiler-0.1.0/tests/fakes.py +116 -0
  33. kiwime_compiler-0.1.0/tests/test_pipeline.py +195 -0
  34. kiwime_compiler-0.1.0/tests/test_providers_anthropic.py +145 -0
  35. kiwime_compiler-0.1.0/tests/test_providers_bedrock.py +105 -0
  36. kiwime_compiler-0.1.0/tests/test_providers_ollama.py +55 -0
  37. kiwime_compiler-0.1.0/tests/test_providers_openai_compat.py +124 -0
  38. kiwime_compiler-0.1.0/tests/test_providers_registry.py +205 -0
  39. kiwime_compiler-0.1.0/tests/test_server_ask.py +168 -0
  40. kiwime_compiler-0.1.0/tests/test_server_resume.py +69 -0
  41. kiwime_compiler-0.1.0/tests/test_store_client_vectors.py +245 -0
  42. kiwime_compiler-0.1.0/tests/test_tasks_capability.py +120 -0
  43. kiwime_compiler-0.1.0/tests/test_tasks_identity.py +156 -0
@@ -0,0 +1,78 @@
1
+ # OS
2
+ .DS_Store
3
+ Thumbs.db
4
+
5
+ # Editors
6
+ .vscode/*
7
+ !.vscode/settings.json
8
+ .idea/
9
+ *.swp
10
+ *.swo
11
+
12
+ # Rust
13
+ target/
14
+ Cargo.lock.bak
15
+
16
+ # Node
17
+ node_modules/
18
+ dist/
19
+ .pnpm-store/
20
+ *.log
21
+
22
+ # Python
23
+ __pycache__/
24
+ *.py[cod]
25
+ *.egg-info/
26
+ .venv/
27
+ .pytest_cache/
28
+ .mypy_cache/
29
+ .ruff_cache/
30
+ .coverage
31
+ htmlcov/
32
+
33
+ # Tauri
34
+ apps/desktop/src-tauri/target/
35
+
36
+ # Frozen Python sidecars (build artifacts, see ADR 0006)
37
+ apps/desktop/src-tauri/binaries/
38
+ target/pyinstaller/
39
+
40
+ # uv
41
+ .python-version
42
+
43
+ # Frontend build copied into the host package by the publish workflow
44
+ apps/host/src/kiwime_host/_ui/
45
+
46
+ # Local data
47
+ *.sqlite
48
+ *.sqlite-journal
49
+ *.db
50
+ data/
51
+ blobs/
52
+
53
+ # Secrets
54
+ .env
55
+ .env.local
56
+ *.pem
57
+
58
+ # Per-user Claude Code settings (personal permission allowlists, local paths)
59
+ .claude/settings.local.json
60
+
61
+ # TypeScript incremental build state
62
+ *.tsbuildinfo
63
+ apps/desktop/dist-types-node/
64
+
65
+ # Internal planning notes (kept out of the public tree; see docs/ for the
66
+ # published roadmap, ADRs, and release docs)
67
+ .internal-notes/
68
+ docs/release/C_NOTES.md
69
+ docs/release/D_NOTES.md
70
+ docs/release/oss-readiness-review.md
71
+ docs/release/implementation-plan-*.md
72
+ docs/release/webification-plan.md
73
+
74
+ # Tool caches / scratch
75
+ .mypy_cache/
76
+ .pytest_cache/
77
+ .ruff_cache/
78
+ .a1/
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: kiwime-compiler
3
+ Version: 0.1.0
4
+ Summary: kiwiMe compiler sidecar — LLM and embedding-driven compilation.
5
+ Project-URL: Homepage, https://github.com/kiwiberry-ai/kiwime
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: boto3>=1.35
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: kiwime-sdk==0.1.0
11
+ Requires-Dist: kiwime-secret==0.1.0
12
+ Requires-Dist: numpy>=2
13
+ Requires-Dist: scikit-learn>=1.5
14
+ Requires-Dist: sqlite-vec>=0.1.9
15
+ Provides-Extra: dev
16
+ Requires-Dist: mypy>=1.10; extra == 'dev'
17
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
18
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,92 @@
1
+ # workers/compiler
2
+
3
+ The Python sidecar that runs LLM- and embedding-driven compilation for kiwiMe. It reads ingested `Document` and `ActivityEvent` rows from `workers/store`, calls a generator (Bedrock Claude) and an embedder (Ollama `nomic-embed-text`), and writes back the compilation-layer objects defined in `docs/schema/core.md`: memories, capabilities, topics, cases, graph nodes/edges.
4
+
5
+ ## Pipeline
6
+
7
+ `pipeline.run(...)` runs seven tasks in order, serially:
8
+
9
+ 1. **identity** — single Identity Memory from top-signal documents (pinned READMEs, "about me" pages).
10
+ 2. **topic** — k-means clustering over document embeddings, LLM-labelled.
11
+ 3. **semantic** — per-topic stable claims about capabilities and methodologies.
12
+ 4. **episodic** — last 90 days of activity events, bucketed by source/kind.
13
+ 5. **capability** — per-topic capability hints with `recency` derived from supporting-doc timestamps.
14
+ 6. **case** — `pr_merged + linked_issue + outcome_doc` triples drafted into Case cards.
15
+ 7. **graph** — projects everything into `GraphNode` / `GraphEdge` with kinds `expresses`, `demonstrates`, `derived_from`, `co_occurs_with`.
16
+
17
+ Every compilation output carries a non-empty `provenance` (or `evidence`) array. Empty provenance is treated as a bug per `docs/schema/core.md`.
18
+
19
+ ## Provider configuration (per-profile)
20
+
21
+ The compiler reads `provider_config` rows from the store. Three roles are recognised:
22
+
23
+ | role | used for | required? |
24
+ | --- | --- | --- |
25
+ | `generator` | compile pipeline (memory / topic / capability extraction) | yes |
26
+ | `embedder` | semantic vectors over the corpus | yes |
27
+ | `assistant` | user-facing chat (Constellation Ask), summarization, AI skill drafting | optional |
28
+
29
+ `generator` and `assistant` are kept distinct so a user can run a small local model for compile (cheap, batch-friendly) while still using Claude / GPT-4 for interactive chat.
30
+
31
+ ```jsonc
32
+ // generator (compile pipeline)
33
+ { "role": "generator", "provider": "bedrock", "config": {
34
+ "region": "us-east-1",
35
+ "model_id": "anthropic.claude-sonnet-4-20250514-v1:0"
36
+ }}
37
+ // embedder (locked to Ollama nomic-embed-text in v1 — UI doesn't expose it)
38
+ { "role": "embedder", "provider": "ollama", "config": {
39
+ "endpoint": "http://localhost:11434",
40
+ "model": "nomic-embed-text"
41
+ }}
42
+ // assistant (user-facing LLM). api_key is a $secret_ref pointing at the OS
43
+ // keychain — the registry resolves it at load time; plaintext keys are never
44
+ // stored in provider_config.
45
+ { "role": "assistant", "provider": "openai_compat", "config": {
46
+ "base_url": "https://api.deepseek.com/v1",
47
+ "api_key": {"$secret_ref": {"namespace": "provider:assistant", "key": "api_key"}},
48
+ "model": "deepseek-chat"
49
+ }}
50
+ // or:
51
+ { "role": "assistant", "provider": "anthropic", "config": {
52
+ "api_key": {"$secret_ref": {"namespace": "provider:assistant", "key": "api_key"}},
53
+ "model": "claude-sonnet-4-20250514"
54
+ }}
55
+ ```
56
+
57
+ Generator providers
58
+ : `bedrock`, `ollama`, `openai_compat` (OpenAI / DeepSeek / Together / Groq / vLLM / any chat-completions-compatible endpoint), `anthropic` (native Messages API). Both `generator` and `assistant` roles accept any of these.
59
+
60
+ There are **no defaults** for Bedrock — missing `region` or `model_id` raises immediately. AWS credentials are not read by the compiler; the host fetches them via `secret.aws_credentials` (env → `~/.aws` → keychain) and injects them at registry-load time. `openai_compat` and `anthropic` likewise refuse to start without an explicit `model` and `api_key`; the UI must collect them.
61
+
62
+ ## Prompt design notes
63
+
64
+ - **Capability tag.** Every prompt is currently sized for a Sonnet-4-class generator. Each call site is implicitly tagged `needs_strong_reasoning`. Local 7B-class models will need different prompts (shorter context, smaller output, more explicit format scaffolding); when we add an `OllamaGenerator`, we will branch prompts on the active provider's capability tag rather than text-substitute. See ADR 0004.
65
+ - **JSON-coerced output.** Structured output is implemented by injecting a `Respond with JSON matching this schema` preamble plus one parse retry on malformed JSON. Empirically ~5% of responses are malformed on first attempt; if both attempts fail we raise `ProviderError` rather than recovering silently.
66
+ - **Provenance discipline.** Tasks drop any LLM-suggested object whose `supporting_doc_ids` are empty or all unknown. The compiler will not invent text without source.
67
+ - **Capability mismatch.** If a configured provider can't meet a task's bar (e.g. small local model returning unparseable JSON twice), the task fails loudly with `CapabilityMismatchError`. This is per ADR 0004 — we do not silently degrade.
68
+
69
+ ## Idempotency
70
+
71
+ IDs for memories, capabilities, topics, cases, nodes, and edges are content-derived (`profile_id` + kind + canonical content hashed to 12 hex chars). Re-running `compile.run` over the same inputs produces the same ids, so the `INSERT OR REPLACE` paths in the store are safe.
72
+
73
+ ## Direct mode (temporary)
74
+
75
+ The compiler is a sidecar; in the target architecture it talks to `workers/store` through the host. The host doesn't yet route worker→worker calls, so until it does the compiler supports a direct-mode shortcut:
76
+
77
+ ```bash
78
+ kiwime-compiler --direct /path/to/kiwime.db
79
+ # or
80
+ KIWIME_COMPILER_DIRECT_DB=/path/to/kiwime.db kiwime-compiler
81
+ ```
82
+
83
+ In direct mode the compiler opens the SQLite database itself read-write, bypassing the host entirely. This is explicitly tagged as temporary in `store_client.py`; `RoutedStoreClient` is a stub that raises `NotImplementedError` until the host routing lands. Tests run against an in-memory `InMemoryStore` fake and never touch SQLite.
84
+
85
+ ## Tests
86
+
87
+ ```bash
88
+ cd workers/compiler
89
+ python -m pytest
90
+ ```
91
+
92
+ Mocks: `boto3` is mocked via `MagicMock`; Ollama is mocked via `pytest-httpx`; tasks and pipeline use the in-memory `FakeGenerator` / `FakeEmbedder` / `InMemoryStore` from `tests/fakes.py`.
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "kiwime-compiler"
3
+ version = "0.1.0"
4
+ description = "kiwiMe compiler sidecar — LLM and embedding-driven compilation."
5
+ requires-python = ">=3.11"
6
+ license = "Apache-2.0"
7
+ dependencies = [
8
+ "kiwime-sdk==0.1.0",
9
+ # Keychain access for resolving $secret_ref entries in provider_config
10
+ # (API keys are never stored in SQLite; see registry._keychain_resolver).
11
+ "kiwime-secret==0.1.0",
12
+ "boto3>=1.35",
13
+ "httpx>=0.27",
14
+ "numpy>=2",
15
+ "scikit-learn>=1.5",
16
+ "sqlite-vec>=0.1.9",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0",
22
+ "pytest-asyncio>=0.23",
23
+ "pytest-httpx>=0.30",
24
+ "mypy>=1.10",
25
+ ]
26
+
27
+ [project.scripts]
28
+ kiwime-compiler = "kiwime_compiler.__main__:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/kiwiberry-ai/kiwime"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/kiwime_compiler"]
39
+
40
+ [tool.uv.sources]
41
+ kiwime-sdk = { workspace = true }
42
+ kiwime-secret = { workspace = true }
43
+
44
+ [tool.pytest.ini_options]
45
+ asyncio_mode = "auto"
46
+ testpaths = ["tests"]
@@ -0,0 +1,8 @@
1
+ """kiwiMe compiler sidecar.
2
+
3
+ Compiles ingested Documents and ActivityEvents into Memory / Capability /
4
+ Topic / Case / GraphNode / GraphEdge objects. See docs/architecture/ipc-protocol.md
5
+ section 4 and docs/schema/core.md.
6
+ """
7
+
8
+ __version__ = "0.0.0"
@@ -0,0 +1,57 @@
1
+ """Sidecar entry point.
2
+
3
+ Two run modes:
4
+
5
+ --direct PATH Open the SQLite DB at PATH read-write. Temporary
6
+ shortcut while the host doesn't yet route
7
+ worker→worker calls. See store_client.py.
8
+
9
+ (no flag) Routed mode — talk to the host. Not implemented yet.
10
+
11
+ Always reads JSON-RPC frames on stdin and writes them to stdout. Logs go
12
+ to stderr.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import os
19
+ import sys
20
+
21
+ from .providers.registry import ProviderRegistry
22
+ from .server import CompilerServer
23
+ from .store_client import DirectStoreClient, RoutedStoreClient
24
+
25
+
26
+ def main() -> int:
27
+ p = argparse.ArgumentParser(prog="kiwime-compiler")
28
+ p.add_argument(
29
+ "--direct",
30
+ metavar="DB_PATH",
31
+ default=os.environ.get("KIWIME_COMPILER_DIRECT_DB"),
32
+ help="Open the SQLite DB directly. Temporary shortcut.",
33
+ )
34
+ p.add_argument("--profile", help="Profile id to compile for. Defaults to first in DB.")
35
+ args = p.parse_args()
36
+
37
+ if args.direct:
38
+ store = DirectStoreClient(args.direct)
39
+ profile_id = args.profile or store.query_default_profile()
40
+ else:
41
+ # When the host can route, this becomes the default path.
42
+ store = RoutedStoreClient() # raises NotImplementedError
43
+ profile_id = args.profile or ""
44
+
45
+ providers = ProviderRegistry()
46
+ try:
47
+ providers.load(store, profile_id) # type: ignore[arg-type]
48
+ except Exception as e:
49
+ print(f"warning: provider registry not loaded: {e}", file=sys.stderr)
50
+
51
+ server = CompilerServer(store=store, providers=providers, profile_id=profile_id)
52
+ server.serve()
53
+ return 0
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
@@ -0,0 +1,30 @@
1
+ """Pipeline Context — the per-run handle passed to every task."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from .providers.registry import ProviderRegistry
10
+ from .store_client import StoreClient
11
+
12
+
13
+ @dataclass
14
+ class Context:
15
+ profile_id: str
16
+ store: StoreClient
17
+ providers: ProviderRegistry
18
+ documents: list[dict[str, Any]] = field(default_factory=list)
19
+ events: list[dict[str, Any]] = field(default_factory=list)
20
+ # Cached cross-task outputs.
21
+ topics: list[dict[str, Any]] = field(default_factory=list)
22
+ capabilities: list[dict[str, Any]] = field(default_factory=list)
23
+ cases: list[dict[str, Any]] = field(default_factory=list)
24
+ memories: list[dict[str, Any]] = field(default_factory=list)
25
+ # Document embeddings cache, keyed by document id.
26
+ doc_embeddings: dict[str, list[float]] = field(default_factory=dict)
27
+ # Progress reporter; the pipeline injects a real one, tasks call it freely.
28
+ progress: Callable[[str, int, int, str | None], None] = field(
29
+ default=lambda phase, done, total, msg=None: None
30
+ )
@@ -0,0 +1,43 @@
1
+ """Deterministic content-derived ids.
2
+
3
+ See docs/schema/core.md "Common conventions / IDs". Every compiled object's
4
+ id is derived from ``(profile_id, kind-specific salt, content-hash)`` so that
5
+ re-running compile produces the same ids — that is what makes
6
+ ``compile.run`` idempotent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+
13
+
14
+ def _h(*parts: str, n: int = 12) -> str:
15
+ h = hashlib.sha256()
16
+ for p in parts:
17
+ h.update(p.encode("utf-8"))
18
+ h.update(b"\x1f")
19
+ return h.hexdigest()[:n]
20
+
21
+
22
+ def memory_id(profile_id: str, kind: str, content: str) -> str:
23
+ return f"mem_{kind}_{_h(profile_id, kind, content)}"
24
+
25
+
26
+ def capability_id(profile_id: str, name: str) -> str:
27
+ return f"cap_{_h(profile_id, name.lower())}"
28
+
29
+
30
+ def topic_id(profile_id: str, label: str) -> str:
31
+ return f"topic_{_h(profile_id, label.lower())}"
32
+
33
+
34
+ def case_id(profile_id: str, title: str) -> str:
35
+ return f"case_{_h(profile_id, title.lower())}"
36
+
37
+
38
+ def node_id(profile_id: str, kind: str, ref: str) -> str:
39
+ return f"node_{kind}_{_h(profile_id, kind, ref)}"
40
+
41
+
42
+ def edge_id(profile_id: str, from_node: str, to_node: str, kind: str) -> str:
43
+ return f"edge_{_h(profile_id, from_node, to_node, kind)}"
@@ -0,0 +1,91 @@
1
+ """Compilation pipeline orchestrator.
2
+
3
+ Serial execution (first version). Order matters:
4
+
5
+ 1. topic — embeds + clusters documents; downstream tasks reuse
6
+ ``ctx.doc_embeddings``
7
+ 2. identity — picks centroid-closest docs from those embeddings and
8
+ synthesizes one identity statement
9
+ 3. semantic — per-topic
10
+ 4. episodic — independent of topics, driven by activity events
11
+ 5. capability — per-topic
12
+ 6. case — uses capabilities for cross-link
13
+ 7. graph — projects everything into nodes/edges
14
+
15
+ Identity used to run first based on filename heuristics, but it now needs
16
+ the embedding cache that ``topic`` populates, so it moved.
17
+
18
+ The pipeline emits ``compile:progress`` notifications via the supplied
19
+ callback. Callers (the JSON-RPC server) wire those into the host.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Callable
25
+ from typing import Any
26
+
27
+ from .context import Context
28
+ from .providers.registry import ProviderRegistry
29
+ from .store_client import StoreClient
30
+ from .tasks import capability as t_capability
31
+ from .tasks import case as t_case
32
+ from .tasks import episodic as t_episodic
33
+ from .tasks import graph as t_graph
34
+ from .tasks import identity as t_identity
35
+ from .tasks import semantic as t_semantic
36
+ from .tasks import topic as t_topic
37
+
38
+ ProgressFn = Callable[[str, int, int, str | None], None]
39
+
40
+ _TASK_ORDER = [
41
+ ("topic", t_topic.run),
42
+ ("identity", t_identity.run),
43
+ ("semantic", t_semantic.run),
44
+ ("episodic", t_episodic.run),
45
+ ("capability", t_capability.run),
46
+ ("case", t_case.run),
47
+ ("graph", t_graph.run),
48
+ ]
49
+
50
+
51
+ def _noop_progress(phase: str, done: int, total: int, msg: str | None = None) -> None:
52
+ pass
53
+
54
+
55
+ def run(
56
+ *,
57
+ profile_id: str,
58
+ store: StoreClient,
59
+ providers: ProviderRegistry,
60
+ since: str | None = None,
61
+ progress: ProgressFn | None = None,
62
+ ) -> dict[str, int]:
63
+ """Run the seven tasks in order; return summary counts."""
64
+ progress = progress or _noop_progress
65
+
66
+ pending = store.list_pending_for_compile(since=since)
67
+ ctx = Context(
68
+ profile_id=profile_id,
69
+ store=store,
70
+ providers=providers,
71
+ documents=pending["documents"],
72
+ events=pending["events"],
73
+ progress=progress,
74
+ )
75
+
76
+ progress("start", 0, len(_TASK_ORDER), f"docs={len(ctx.documents)} events={len(ctx.events)}")
77
+
78
+ results: dict[str, Any] = {}
79
+ for i, (name, fn) in enumerate(_TASK_ORDER):
80
+ progress(name, i, len(_TASK_ORDER), "starting")
81
+ results[name] = fn(ctx)
82
+ progress(name, i + 1, len(_TASK_ORDER), "done")
83
+
84
+ return {
85
+ "memories": len(ctx.memories),
86
+ "capabilities": len(ctx.capabilities),
87
+ "topics": len(ctx.topics),
88
+ "cases": len(ctx.cases),
89
+ "edges": int(results["graph"].get("edge_count", 0)) if results.get("graph") else 0,
90
+ "nodes": int(results["graph"].get("node_count", 0)) if results.get("graph") else 0,
91
+ }
@@ -0,0 +1 @@
1
+ """Provider layer — Generator / Embedder / Reranker (see ADR 0004)."""
@@ -0,0 +1,189 @@
1
+ """Anthropic Messages API generator.
2
+
3
+ Why a dedicated class instead of folding it into ``openai_compat``
4
+ Anthropic's ``/v1/messages`` is *not* OpenAI-compatible: ``system``
5
+ is a top-level field (not a message), ``max_tokens`` is required,
6
+ response shape is ``{content: [{type:"text", text:"..."}], ...}``,
7
+ auth is ``x-api-key`` instead of bearer, and a separate
8
+ ``anthropic-version`` header is required. Forcing it into the
9
+ chat-completions schema would just produce a maze of ifs.
10
+
11
+ JSON coercion
12
+ Anthropic doesn't expose a JSON-mode toggle (yet). We emulate the
13
+ same recipe as the other providers: schema preamble in ``system``,
14
+ parse, retry once on failure with an explicit error turn.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from .base import ProviderError
25
+
26
+ _DEFAULT_BASE_URL = "https://api.anthropic.com"
27
+ _DEFAULT_VERSION = "2023-06-01"
28
+
29
+
30
+ class AnthropicGenerator:
31
+ def __init__(
32
+ self,
33
+ *,
34
+ api_key: str,
35
+ model: str,
36
+ base_url: str = _DEFAULT_BASE_URL,
37
+ anthropic_version: str = _DEFAULT_VERSION,
38
+ timeout: float = 120.0,
39
+ extra_headers: dict[str, str] | None = None,
40
+ ) -> None:
41
+ if not model:
42
+ raise ProviderError(
43
+ "Anthropic generator requires a model id.", kind="permanent"
44
+ )
45
+ self.api_key = api_key
46
+ self.model = model
47
+ self.base_url = base_url.rstrip("/")
48
+ self.anthropic_version = anthropic_version
49
+ self.timeout = timeout
50
+ self.extra_headers = dict(extra_headers or {})
51
+
52
+ # ------------------------------------------------------------------ Generator
53
+ def generate(
54
+ self,
55
+ prompt: str,
56
+ system: str | None = None,
57
+ *,
58
+ max_tokens: int = 1024,
59
+ temperature: float = 0.2,
60
+ json_schema: dict[str, Any] | None = None,
61
+ ) -> str:
62
+ effective_system = system or ""
63
+ if json_schema is not None:
64
+ schema_preamble = (
65
+ "Respond with JSON matching this schema. Output JSON only, "
66
+ "no prose, no markdown fences.\n\nSchema:\n"
67
+ + json.dumps(json_schema, ensure_ascii=False)
68
+ )
69
+ effective_system = (
70
+ effective_system + "\n\n" + schema_preamble
71
+ if effective_system
72
+ else schema_preamble
73
+ )
74
+
75
+ messages: list[dict[str, str]] = [{"role": "user", "content": prompt}]
76
+ text = self._messages(
77
+ system=effective_system,
78
+ messages=messages,
79
+ temperature=temperature,
80
+ max_tokens=max_tokens,
81
+ )
82
+
83
+ if json_schema is None:
84
+ return text
85
+
86
+ stripped = _strip_fences(text)
87
+ try:
88
+ json.loads(stripped)
89
+ return stripped
90
+ except json.JSONDecodeError as first_err:
91
+ retry_messages = [
92
+ *messages,
93
+ {"role": "assistant", "content": text},
94
+ {
95
+ "role": "user",
96
+ "content": (
97
+ f"Your last response was not valid JSON, here is the "
98
+ f"error: {first_err}. Output ONLY a JSON object "
99
+ f"matching the schema, with no other text."
100
+ ),
101
+ },
102
+ ]
103
+ text2 = self._messages(
104
+ system=effective_system,
105
+ messages=retry_messages,
106
+ temperature=temperature,
107
+ max_tokens=max_tokens,
108
+ )
109
+ stripped2 = _strip_fences(text2)
110
+ try:
111
+ json.loads(stripped2)
112
+ except json.JSONDecodeError as second_err:
113
+ raise ProviderError(
114
+ f"Anthropic JSON coercion failed after retry: {second_err}",
115
+ kind="permanent",
116
+ ) from second_err
117
+ return stripped2
118
+
119
+ # ------------------------------------------------------------------ internals
120
+ def _messages(
121
+ self,
122
+ *,
123
+ system: str,
124
+ messages: list[dict[str, str]],
125
+ temperature: float,
126
+ max_tokens: int,
127
+ ) -> str:
128
+ url = f"{self.base_url}/v1/messages"
129
+ payload: dict[str, Any] = {
130
+ "model": self.model,
131
+ "messages": messages,
132
+ "max_tokens": int(max_tokens),
133
+ "temperature": float(temperature),
134
+ }
135
+ if system:
136
+ payload["system"] = system
137
+
138
+ headers = {
139
+ "x-api-key": self.api_key,
140
+ "anthropic-version": self.anthropic_version,
141
+ "Content-Type": "application/json",
142
+ **self.extra_headers,
143
+ }
144
+
145
+ try:
146
+ with httpx.Client(timeout=self.timeout) as client:
147
+ r = client.post(url, json=payload, headers=headers)
148
+ except httpx.HTTPError as e:
149
+ raise ProviderError(
150
+ f"Anthropic messages call failed: {e}", kind="transient"
151
+ ) from e
152
+
153
+ if r.status_code >= 400:
154
+ raise ProviderError(
155
+ f"Anthropic messages returned {r.status_code}: {r.text[:200]}",
156
+ kind="transient" if r.status_code >= 500 else "permanent",
157
+ )
158
+ try:
159
+ data = r.json()
160
+ except ValueError as e:
161
+ raise ProviderError(
162
+ f"Anthropic messages returned non-JSON envelope: {e}"
163
+ ) from e
164
+
165
+ # ``content`` is a list of blocks; we concatenate every text block.
166
+ # Tool-use blocks (rare in our flow) get skipped — generators only
167
+ # need the text part.
168
+ content = data.get("content") or []
169
+ parts: list[str] = []
170
+ for block in content:
171
+ if isinstance(block, dict) and block.get("type") == "text":
172
+ t = block.get("text")
173
+ if isinstance(t, str):
174
+ parts.append(t)
175
+ if not parts:
176
+ raise ProviderError(
177
+ f"Anthropic messages returned no text content: {data!r}"
178
+ )
179
+ return "".join(parts)
180
+
181
+
182
+ def _strip_fences(text: str) -> str:
183
+ s = text.strip()
184
+ if s.startswith("```"):
185
+ s = s.split("\n", 1)[1] if "\n" in s else s[3:]
186
+ if s.endswith("```"):
187
+ s = s[:-3]
188
+ s = s.strip()
189
+ return s