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.
- kiwime_compiler-0.1.0/.gitignore +78 -0
- kiwime_compiler-0.1.0/PKG-INFO +19 -0
- kiwime_compiler-0.1.0/README.md +92 -0
- kiwime_compiler-0.1.0/pyproject.toml +46 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/__init__.py +8 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/__main__.py +57 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/context.py +30 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/ids.py +43 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/pipeline.py +91 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/providers/__init__.py +1 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/providers/anthropic.py +189 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/providers/base.py +75 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/providers/bedrock.py +174 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/providers/ollama.py +72 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/providers/ollama_generator.py +159 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/providers/openai_compat.py +221 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/providers/registry.py +219 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/py.typed +0 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/server.py +423 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/store_client.py +593 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/__init__.py +6 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/_common.py +63 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/capability.py +114 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/case.py +114 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/episodic.py +94 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/graph.py +124 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/identity.py +146 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/semantic.py +98 -0
- kiwime_compiler-0.1.0/src/kiwime_compiler/tasks/topic.py +86 -0
- kiwime_compiler-0.1.0/tests/__init__.py +0 -0
- kiwime_compiler-0.1.0/tests/conftest.py +13 -0
- kiwime_compiler-0.1.0/tests/fakes.py +116 -0
- kiwime_compiler-0.1.0/tests/test_pipeline.py +195 -0
- kiwime_compiler-0.1.0/tests/test_providers_anthropic.py +145 -0
- kiwime_compiler-0.1.0/tests/test_providers_bedrock.py +105 -0
- kiwime_compiler-0.1.0/tests/test_providers_ollama.py +55 -0
- kiwime_compiler-0.1.0/tests/test_providers_openai_compat.py +124 -0
- kiwime_compiler-0.1.0/tests/test_providers_registry.py +205 -0
- kiwime_compiler-0.1.0/tests/test_server_ask.py +168 -0
- kiwime_compiler-0.1.0/tests/test_server_resume.py +69 -0
- kiwime_compiler-0.1.0/tests/test_store_client_vectors.py +245 -0
- kiwime_compiler-0.1.0/tests/test_tasks_capability.py +120 -0
- 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,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
|