Compare commits
74 Commits
codex/dali
...
codex/open
| Author | SHA1 | Date | |
|---|---|---|---|
| 57b64523fb | |||
| a13ea3b9d1 | |||
| 3f23ca1bc6 | |||
| c1f5b3bdee | |||
| 761c483474 | |||
| c57617f611 | |||
| 3f18ba3b35 | |||
| 8527c369ee | |||
| bd3dc50100 | |||
| 700e3ca2c2 | |||
| ccc49d3a8f | |||
| 3e0a357441 | |||
| dc20033a93 | |||
| b86181eb6c | |||
| 9118f824fa | |||
| db89978871 | |||
| 4ac4e5cc44 | |||
| a6ae6166a4 | |||
| 4f8bec7419 | |||
| 52380a233e | |||
| 8b77e83f0a | |||
| dbb8f915e2 | |||
| e5e9a9931e | |||
| 144dbbd700 | |||
| 7650c339a2 | |||
| 69c971708a | |||
| 8951c624fe | |||
| 1a2ee5e07f | |||
| 9b149d4bfd | |||
| abc8af5f7e | |||
| ac7f77d86d | |||
| 719ff649a8 | |||
| 8af8af90d0 | |||
| cd0fd390a8 | |||
| c67bec095c | |||
| bcb7675a0d | |||
| 54d84b52cb | |||
| b790e7eb30 | |||
| e2895b5d2b | |||
| 2b79680167 | |||
| 39d73e91b4 | |||
| 7ddf0e38ee | |||
| b0fde3ee60 | |||
| 89c7964237 | |||
| 146f2e4a5e | |||
| 5c69f77b45 | |||
| 3921c5ffc7 | |||
| 93f796207f | |||
| b98a658831 | |||
| 06792d862e | |||
| 95daa5c040 | |||
| 3a7e8ccba4 | |||
| a29b5e22f2 | |||
| b309e7fd49 | |||
| 330ecfb6a6 | |||
| 7d8d599030 | |||
| d9dc55f841 | |||
| 81307cec47 | |||
| 59331e522d | |||
| b3253f35ee | |||
| 30ee857d62 | |||
| 38f6e525af | |||
| 37331d53ef | |||
| 5aeeb1cad1 | |||
| 4da81c9e4e | |||
| 7bf83bf46a | |||
| 1161645415 | |||
| 5913da53c5 | |||
| 8ea53f4003 | |||
| 9366ba7879 | |||
| c5bad996a7 | |||
| 0b1742770a | |||
| 2829d5ec1c | |||
| f49637b5cc |
@@ -1,5 +1,13 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Session protocol (read first, every session)
|
||||
|
||||
**Before doing anything else, read `DEV-LEDGER.md` at the repo root.** It is the one-file source of truth for "what is currently true" — live SHA, active plan, open review findings, recent decisions. The narrative docs under `docs/` may lag; the ledger does not.
|
||||
|
||||
**Before ending a session, append a Session Log line to `DEV-LEDGER.md`** with what you did and which commit range it covers, and bump the Orientation section if anything there changed.
|
||||
|
||||
This rule applies equally to Claude, Codex, and any future agent working in this repo.
|
||||
|
||||
## Project role
|
||||
This repository is AtoCore, the runtime and machine-memory layer of the Ato ecosystem.
|
||||
|
||||
|
||||
30
CLAUDE.md
Normal file
30
CLAUDE.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# CLAUDE.md — project instructions for AtoCore
|
||||
|
||||
## Session protocol
|
||||
|
||||
Before doing anything else in this repo, read `DEV-LEDGER.md` at the repo root. It is the shared operating memory between Claude, Codex, and the human operator — live Dalidou SHA, active plan, open P1/P2 review findings, recent decisions, and session log. The narrative docs under `docs/` sometimes lag; the ledger does not.
|
||||
|
||||
Before ending a session, append a Session Log line to `DEV-LEDGER.md` covering:
|
||||
|
||||
- which commits you produced (sha range)
|
||||
- what changed at a high level
|
||||
- any harness / test count deltas
|
||||
- anything you overclaimed and later corrected
|
||||
|
||||
Bump the **Orientation** section if `live_sha`, `main_tip`, `test_count`, or `harness` changed.
|
||||
|
||||
`AGENTS.md` at the repo root carries the broader project principles (storage separation, deployment model, coding guidance). Read it when you need the "why" behind a constraint.
|
||||
|
||||
## Deploy workflow
|
||||
|
||||
```bash
|
||||
git push origin main && ssh papa@dalidou "bash /srv/storage/atocore/app/deploy/dalidou/deploy.sh"
|
||||
```
|
||||
|
||||
The deploy script self-verifies via `/health` build_sha — if it exits non-zero, do not assume the change is live.
|
||||
|
||||
## Working model
|
||||
|
||||
- Claude builds; Codex audits. No parallel work on the same files.
|
||||
- P1 review findings block further `main` commits until acknowledged in the ledger's **Open Review Findings** table.
|
||||
- Codex branches must fork from `origin/main` (no orphan commits that require `--allow-unrelated-histories`).
|
||||
204
DEV-LEDGER.md
Normal file
204
DEV-LEDGER.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# AtoCore Dev Ledger
|
||||
|
||||
> Shared operating memory between humans, Claude, and Codex.
|
||||
> **Every session MUST read this file at start and append a Session Log entry before ending.**
|
||||
> Section headers are stable - do not rename them. Trim Session Log and Recent Decisions to the last 20 entries at session end; older history lives in `git log` and `docs/`.
|
||||
|
||||
## Orientation
|
||||
|
||||
- **live_sha** (Dalidou `/health` build_sha): `4f8bec7` (dashboard endpoint live)
|
||||
- **last_updated**: 2026-04-12 by Claude (full session docs sync)
|
||||
- **main_tip**: `4ac4e5c` (includes OpenClaw capture plugin merge)
|
||||
- **test_count**: 290 passing
|
||||
- **harness**: `17/18 PASS` (only p06-tailscale — chunk bleed, not a memory/ranking issue)
|
||||
- **vectors**: 33,253 (was 20,781; +12,472 from atomizer-v2 ingestion)
|
||||
- **active_memories**: 47 (16 project, 16 knowledge, 6 adaptation, 3 identity, 3 preference, 3 episodic)
|
||||
- **candidate_memories**: 0
|
||||
- **registered_projects**: p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, atocore
|
||||
- **project_state_entries**: p04=5, p05=9, p06=9, atocore=38 (61 total)
|
||||
- **off_host_backup**: `papa@192.168.86.39:/home/papa/atocore-backups/` via cron, verified
|
||||
- **nightly_pipeline**: backup → cleanup → rsync → LLM extraction (sonnet) → auto-triage (sonnet)
|
||||
- **capture_clients**: claude-code (Stop hook), openclaw (plugin)
|
||||
|
||||
## Active Plan
|
||||
|
||||
**Mini-phase**: Extractor improvement (eval-driven) + retrieval harness expansion.
|
||||
**Duration**: 8 days, hard gates at each day boundary.
|
||||
**Plan author**: Codex (2026-04-11). **Executor**: Claude. **Audit**: Codex.
|
||||
|
||||
### Preflight (before Day 1)
|
||||
|
||||
Stop if any of these fail:
|
||||
|
||||
- `git rev-parse HEAD` on `main` matches the expected branching tip
|
||||
- Live `/health` on Dalidou reports the SHA you think is deployed
|
||||
- `python scripts/retrieval_eval.py --json` still passes at the current baseline
|
||||
- `batch-extract` over the known 42-capture slice reproduces the current low-yield baseline
|
||||
- A frozen sample set exists for extractor labeling so the target does not move mid-phase
|
||||
|
||||
Success: baseline eval output saved, baseline extract output saved, working branch created from `origin/main`.
|
||||
|
||||
### Day 1 - Labeled extractor eval set
|
||||
|
||||
Pick 30 real captures: 10 that should produce 0 candidates, 10 that should plausibly produce 1, 10 ambiguous/hard. Store as a stable artifact (interaction id, expected count, expected type, notes). Add a runner that scores extractor output against labels.
|
||||
|
||||
Success: 30 labeled interactions in a stable artifact, one-command precision/recall output.
|
||||
Fail-early: if labeling 30 takes more than a day because the concept is unclear, tighten the extraction target before touching code.
|
||||
|
||||
### Day 2 - Measure current extractor
|
||||
|
||||
Run the rule-based extractor on all 30. Record yield, TP, FP, FN. Bucket misses by class (conversational preference, decision summary, status/constraint, meta chatter).
|
||||
|
||||
Success: short scorecard with counts by miss type, top 2 miss classes obvious.
|
||||
Fail-early: if the labeled set shows fewer than 5 plausible positives total, the corpus is too weak - relabel before tuning.
|
||||
|
||||
### Day 3 - Smallest rule expansion for top miss class
|
||||
|
||||
Add 1-2 narrow, explainable rules for the worst miss class. Add unit tests from real paraphrase examples in the labeled set. Then rerun eval.
|
||||
|
||||
Success: recall up on the labeled set, false positives do not materially rise, new tests cover the new cue class.
|
||||
Fail-early: if one rule expansion raises FP above ~20% of extracted candidates, revert or narrow before adding more.
|
||||
|
||||
### Day 4 - Decision gate: more rules or LLM-assisted prototype
|
||||
|
||||
If rule expansion reaches a **meaningfully reviewable queue**, keep going with rules. Otherwise prototype an LLM-assisted extraction mode behind a flag.
|
||||
|
||||
"Meaningfully reviewable queue":
|
||||
- >= 15-25% candidate yield on the 30 labeled captures
|
||||
- FP rate low enough that manual triage feels tolerable
|
||||
- >= 2 real non-synthetic candidates worth review
|
||||
|
||||
Hard stop: if candidate yield is still under 10% after this point, stop rule tinkering and switch to architecture review (LLM-assisted OR narrower extraction scope).
|
||||
|
||||
### Day 5 - Stabilize and document
|
||||
|
||||
Add remaining focused rules or the flagged LLM-assisted path. Write down in-scope and out-of-scope utterance kinds.
|
||||
|
||||
Success: labeled eval green against target threshold, extractor scope explainable in <= 5 bullets.
|
||||
|
||||
### Day 6 - Retrieval harness expansion (6 -> 15-20 fixtures)
|
||||
|
||||
Grow across p04/p05/p06. Include short ambiguous prompts, cross-project collision cases, expected project-state wins, expected project-memory wins, and 1-2 "should fail open / low confidence" cases.
|
||||
|
||||
Success: >= 15 fixtures, each active project has easy + medium + hard cases.
|
||||
Fail-early: if fixtures are mostly obvious wins, add harder adversarial cases before claiming coverage.
|
||||
|
||||
### Day 7 - Regression pass and calibration
|
||||
|
||||
Run harness on current code vs live Dalidou. Inspect failures (ranking, ingestion gap, project bleed, budget). Make at most ONE ranking/budget tweak if the harness clearly justifies it. Do not mix harness expansion and ranking changes in a single commit unless tightly coupled.
|
||||
|
||||
Success: harness still passes or improves after extractor work; any ranking tweak is justified by a concrete fixture delta.
|
||||
Fail-early: if > 20-25% of harness fixtures regress after extractor changes, separate concerns before merging.
|
||||
|
||||
### Day 8 - Merge and close
|
||||
|
||||
Clean commit sequence. Save before/after metrics (extractor scorecard, harness results). Update docs only with claims the metrics support.
|
||||
|
||||
Merge order: labeled corpus + runner -> extractor improvements + tests -> harness expansion -> any justified ranking tweak -> docs sync last.
|
||||
|
||||
Success: point to a before/after delta for both extraction and retrieval; docs do not overclaim.
|
||||
|
||||
### Hard Gates (stop/rethink points)
|
||||
|
||||
- Extractor yield < 10% after 30 labeled interactions -> stop, reconsider rule-only extraction
|
||||
- FP rate > 20% on labeled set -> narrow rules before adding more
|
||||
- Harness expansion finds < 3 genuinely hard cases -> harness still too soft
|
||||
- Ranking change improves one project but regresses another -> do not merge without explicit tradeoff note
|
||||
|
||||
### Branching
|
||||
|
||||
One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-harness-expansion` for Day 6-7. Keeps extraction and retrieval judgments auditable.
|
||||
|
||||
## Review Protocol
|
||||
|
||||
- Codex records review findings in **Open Review Findings**.
|
||||
- Claude must read **Open Review Findings** at session start before coding.
|
||||
- Codex owns finding text. Claude may update operational fields only:
|
||||
- `status`
|
||||
- `owner`
|
||||
- `resolved_by`
|
||||
- If Claude disagrees with a finding, do not rewrite it. Mark it `declined` and explain why in the **Session Log**.
|
||||
- Any commit or session that addresses a finding should reference the finding id in the commit message or **Session Log**.
|
||||
- `P1` findings block further commits in the affected area until they are at least acknowledged and explicitly tracked.
|
||||
- Findings may be code-level, claim-level, or ops-level. If the implementation boundary changes, retarget the finding instead of silently closing it.
|
||||
|
||||
## Open Review Findings
|
||||
|
||||
| id | finder | severity | file:line | summary | status | owner | opened_at | resolved_by |
|
||||
|-----|--------|----------|------------------------------------|-------------------------------------------------------------------------|--------------|--------|------------|-------------|
|
||||
| R1 | Codex | P1 | deploy/hooks/capture_stop.py:76-85 | Live Claude capture still omits `extract`, so "loop closed both sides" remains overstated in practice even though the API supports it | fixed | Claude | 2026-04-11 | c67bec0 |
|
||||
| R2 | Codex | P1 | src/atocore/context/builder.py | Project memories excluded from pack | fixed | Claude | 2026-04-11 | 8ea53f4 |
|
||||
| R3 | Claude | P2 | src/atocore/memory/extractor.py | Rule cues (`## Decision:`) never fire on conversational LLM text | open | Claude | 2026-04-11 | |
|
||||
| R4 | Codex | P2 | DEV-LEDGER.md:11 | Orientation `main_tip` was stale versus `HEAD` / `origin/main` | fixed | Codex | 2026-04-11 | 81307ce |
|
||||
| R5 | Codex | P1 | src/atocore/interactions/service.py:157-174 | The deployed extraction path still calls only the rule extractor; the new LLM extractor is eval/script-only, so Day 4 "gate cleared" is true as a benchmark result but not as an operational extraction path | fixed | Claude | 2026-04-12 | c67bec0 |
|
||||
| R6 | Codex | P1 | src/atocore/memory/extractor_llm.py:258-276 | LLM extraction accepts model-supplied `project` verbatim with no fallback to `interaction.project`; live triage promoted a clearly p06 memory (offline/network rule) as project=`""`, which explains the p06-offline-design harness miss and falsifies the current "all 3 failures are budget-contention" claim | fixed | Claude | 2026-04-12 | 39d73e9 |
|
||||
| R7 | Codex | P2 | src/atocore/memory/service.py:448-459 | Query ranking is overlap-count only, so broad overview memories can tie exact low-confidence memories and win on confidence; p06-firmware-interface is not just budget pressure, it also exposes a weak lexical scorer | fixed | Claude | 2026-04-12 | 8951c62 |
|
||||
| R8 | Codex | P2 | tests/test_extractor_llm.py:1-7 | LLM extractor tests stop at parser/failure contracts; there is no automated coverage for the script-only persistence/review path that produced the 16 promoted memories, including project-scope preservation | fixed | Claude | 2026-04-12 | 69c9717 |
|
||||
| R9 | Codex | P2 | src/atocore/memory/extractor_llm.py:258-259 | The R6 fallback only repairs empty project output. A wrong non-empty model project still overrides the interaction's known scope, so project attribution is improved but not yet trust-preserving. | fixed | Claude | 2026-04-12 | e5e9a99 |
|
||||
| R10 | Codex | P2 | docs/master-plan-status.md:31-33 | "Phase 8 - OpenClaw Integration" is fair as a baseline milestone, but not as a "primary" integration claim. `t420-openclaw/atocore.py` currently covers a narrow read-oriented subset (13 request shapes vs 32 API routes) plus fail-open health, while memory/interactions/admin write paths remain out of surface. | open | Claude | 2026-04-12 | |
|
||||
| R11 | Codex | P2 | src/atocore/api/routes.py:773-845 | `POST /admin/extract-batch` still accepts `mode="llm"` inside the container and returns a successful 0-candidate result instead of surfacing that host-only LLM extraction is unavailable from this runtime. That is a misleading API contract for operators. | open | Claude | 2026-04-12 | |
|
||||
| R12 | Codex | P2 | scripts/batch_llm_extract_live.py:39-190 | The host-side extractor duplicates the LLM system prompt and JSON parsing logic from `src/atocore/memory/extractor_llm.py`. It works today, but this is now a prompt/parser drift risk across the container and host implementations. | open | Claude | 2026-04-12 | |
|
||||
| R13 | Codex | P2 | DEV-LEDGER.md:12 | The new `286 passing` test-count claim is not reproducibly auditable from the current audit environments: neither Dalidou nor the clean worktree has `pytest` available. The claim may be true in Claude's dev shell, but it remains unverified in this audit. | open | Claude | 2026-04-12 | |
|
||||
|
||||
## Recent Decisions
|
||||
|
||||
- **2026-04-12** Day 4 gate cleared: LLM-assisted extraction via `claude -p` (OAuth, no API key) is the path forward. Rule extractor stays as default for structural cues. *Proposed by:* Claude. *Ratified by:* Antoine.
|
||||
- **2026-04-12** First live triage: 16 promoted, 35 rejected from 51 LLM-extracted candidates. 31% accept rate. Active memory count 20->36. *Executed by:* Claude. *Ratified by:* Antoine.
|
||||
- **2026-04-12** No API keys allowed in AtoCore — LLM-assisted features use OAuth via `claude -p` or equivalent CLI-authenticated paths. *Proposed by:* Antoine.
|
||||
- **2026-04-12** Multi-model extraction direction: extraction/triage should be model-agnostic, with Codex/Gemini/Ollama as second-pass reviewers for robustness. *Proposed by:* Antoine.
|
||||
- **2026-04-11** Adopt this ledger as shared operating memory between Claude and Codex. *Proposed by:* Antoine. *Ratified by:* Antoine.
|
||||
- **2026-04-11** Accept Codex's 8-day mini-phase plan verbatim as Active Plan. *Proposed by:* Codex. *Ratified by:* Antoine.
|
||||
- **2026-04-11** Review findings live in `DEV-LEDGER.md` with Codex owning finding text and Claude updating status fields only. *Proposed by:* Codex. *Ratified by:* Antoine.
|
||||
- **2026-04-11** Project memories land in the pack under `--- Project Memories ---` at 25% budget ratio, gated on canonical project hint. *Proposed by:* Claude.
|
||||
- **2026-04-11** Extraction stays off the capture hot path. Batch / manual only. *Proposed by:* Antoine.
|
||||
- **2026-04-11** 4-step roadmap: extractor -> harness expansion -> Wave 2 ingestion -> OpenClaw finish. Steps 1+2 as one mini-phase. *Ratified by:* Antoine.
|
||||
- **2026-04-11** Codex branches must fork from `main`, not be orphan commits. *Proposed by:* Claude. *Agreed by:* Codex.
|
||||
|
||||
## Session Log
|
||||
|
||||
- **2026-04-12 Claude** `4f8bec7..4ac4e5c` Session close. Merged OpenClaw capture plugin, ingested atomizer-v2 (568 docs, 12,472 new vectors → 33,253 total), seeded Phase 4 identity/preference memories (6 new, 47 total active), added deeper Wave 2 state entries (p05 +3, p06 +3), fixed R9 project trust hierarchy (7 case tests), built auto-triage pipeline, observability dashboard at /admin/dashboard. Updated master-plan-status.md and DEV-LEDGER.md to reflect full current state. 7/14 phases baseline complete. All P1s closed. Nightly pipeline runs unattended with both Claude Code and OpenClaw feeding the reflection loop.
|
||||
- **2026-04-12 Codex (branch `codex/openclaw-capture-plugin`)** added a minimal external OpenClaw plugin at `openclaw-plugins/atocore-capture/` that mirrors Claude Code capture semantics: user-triggered assistant turns are POSTed to AtoCore `/interactions` with `client="openclaw"` and `reinforce=true`, fail-open, no extraction in-path. For live verification, temporarily added the local plugin load path to OpenClaw config and restarted the gateway so the plugin can load. Branch truth is ready; end-to-end verification still needs one fresh post-restart OpenClaw user turn to confirm new `client=openclaw` interactions appear on Dalidou.
|
||||
- **2026-04-12 Claude** Batch 3 (R9 fix): `144dbbd..e5e9a99`. Trust hierarchy for project attribution — interaction scope always wins when set, model project only used for unscoped interactions + registered check. 7 case tests (A-G) cover every combination. Harness 17/18 (no regression). Tests 286->290. Before: wrong registered project could silently override interaction scope. After: interaction.project is the strongest signal; model project is only a fallback for unscoped captures. Not yet guaranteed: nothing prevents the *same* project's model output from being semantically wrong within that project. R9 marked fixed.
|
||||
|
||||
- **2026-04-12 Codex (audit branch `codex/audit-batch2`)** audited `69c9717..origin/main` against the current branch tip and live Dalidou. Verified: live build is `8951c62`, retrieval harness improved to **17/18 PASS**, candidate queue is now empty, active memories rose to **41**, and `python3 scripts/auto_triage.py --dry-run --base-url http://127.0.0.1:8100` runs cleanly on Dalidou but only exercised the empty-queue path. Updated R7 to **fixed** (`8951c62`) and R8 to **fixed** (`69c9717`). Kept R9 **open** because project trust-preservation still allows a wrong non-empty registered project from the model to override the interaction scope. Added R13 because the new `286 passing` claim could not be independently reproduced in this audit: `pytest` is absent on both Dalidou and the clean audit worktree. Also corrected stale Orientation fields (live SHA, main tip, harness, active/candidate memory counts).
|
||||
- **2026-04-12 Codex (audit branch `codex/audit-2026-04-12-extraction`)** audited `54d84b5..ac7f77d` with live Dalidou verification. Confirmed the host-side LLM extraction pipeline is operational: nightly cron points at `deploy/dalidou/cron-backup.sh`, Step 4 calls `deploy/dalidou/batch-extract.sh`, the batch script exists/executable on Dalidou, and a manual host-side run produced candidates successfully. Updated R1 and R5 to **fixed** (`c67bec0`) because extraction now runs unattended off-container. Live state during audit: build `39d73e9`, active memories **36**, candidate queue **29** (16 existing + 13 added by manual verification run), and `last_extract_batch_run` populated in AtoCore project state. Added R11-R12 for the misleading container `mode=llm` no-op and host/container prompt-parser duplication. Security note: CLI positional prompt/response text is visible in process args while `claude -p` runs; acceptable on a single-user home host, but worth remembering if Dalidou's trust boundary changes.
|
||||
- **2026-04-12 Codex (audit branch `codex/audit-2026-04-12-final`)** audited `c5bad99..e2895b5` against origin/main, live Dalidou, and the OpenClaw client script. Live state checked: build `39d73e9`, harness reproducible at **16/18 PASS**, active memories **36**, and `t420-openclaw/atocore.py health` fails open correctly with `fail_open=true`. Spot-checks of Wave 2 project-state entries matched their cited vault docs. Updated R5-R8 status reality (R6 fixed by `39d73e9`), added R9-R10, and corrected Orientation `main_tip` to `e2895b5` because the ledger had drifted behind origin/main. Note: live Dalidou is still on `39d73e9`, so branch-truth and deploy-truth are not the same yet.
|
||||
- **2026-04-12 Claude** Wave 2 trusted operational ingestion + codex audit response. Read 6 vault docs, created 8 new Trusted Project State entries (p04 +2, p05 +3, p06 +3). Fixed R6 (project fallback in LLM extractor) per codex audit. Fixed misscoped p06 offline memory on live Dalidou. Merged codex/audit-2026-04-12. Switched default LLM model from haiku to sonnet. Harness 15/18 -> 16/18. Tests 278 -> 280. main_tip 146f2e4 -> 39d73e9.
|
||||
|
||||
- **2026-04-12 Codex (audit branch `codex/audit-2026-04-12`)** audited `c5bad99..146f2e4` against code, live Dalidou, and the 36 active memories. Confirmed: `claude -p` invocation is not shell-injection-prone (`subprocess.run(args)` with no shell), off-host backup wiring matches the ledger, and R1 remains unresolved in practice. Added R5-R8. Corrected Orientation `main_tip` (`146f2e4`, not `5c69f77`) and tightened the harness note: p06-firmware-interface is a ranking-tie issue, p06-offline-design comes from a project-scope miss in live triage, and p06-tailscale is retrieved-chunk bleed rather than memory-band budget contention.
|
||||
- **2026-04-12 Claude** `06792d8..5c69f77` Day 5-8 close. Documented extractor scope (5 in-scope, 6 out-of-scope categories). Expanded harness from 6 to 18 fixtures (p04 +1, p05 +1, p06 +7, adversarial +2). Per-entry memory cap at 250 chars fixed 1 of 4 budget-contention failures. Final harness: 15/18 PASS. Mini-phase complete. Before/after: rule extractor 0% recall -> LLM 100%; harness 6/6 -> 15/18; active memories 20 -> 36.
|
||||
- **2026-04-12 Claude** `330ecfb..06792d8` (merged eval-loop branch + triage). Day 1-4 of the mini-phase completed in one session. Day 2 baseline: rule extractor 0% recall, 5 distinct miss classes. Day 4 gate cleared: LLM extractor (claude -p haiku, OAuth) hit 100% recall, 2.55 yield/interaction. Refactored from anthropic SDK to subprocess after "no API key" rule. First live triage: 51 candidates -> 16 promoted, 35 rejected. Active memories 20->36. p06-polisher went from 2 to 16 memories (firmware/telemetry architecture set). POST /memory now accepts status field. Test count 264->278.
|
||||
- **2026-04-11 Claude** `claude/extractor-eval-loop @ 7d8d599` — Day 1+2 of the mini-phase. Froze a 64-interaction snapshot (`scripts/eval_data/interactions_snapshot_2026-04-11.json`) and labeled 20 by length-stratified random sample (5 positive, 15 zero; 7 total expected candidates). Built `scripts/extractor_eval.py` as a file-based eval runner. **Day 2 baseline: rule extractor hit 0% yield / 0% recall / 0% precision on the labeled set; 5 false negatives across 5 distinct miss classes (recommendation_prose, architectural_change_summary, spec_update_announcement, layered_recommendation, alignment_assertion).** This is the Day 4 hard-stop signal arriving two days early — a single rule expansion cannot close a 5-way miss, and widening rules blindly will collapse precision. The Day 4 decision gate is escalated to Antoine for ratification before Day 3 touches any extractor code. No extractor code on main has changed.
|
||||
- **2026-04-11 Codex (ledger audit)** fixed stale `main_tip`, retargeted R1 from the API surface to the live Claude Stop hook, and formalized the review write protocol so Claude can consume findings without rewriting them.
|
||||
- **2026-04-11 Claude** `b3253f3..59331e5` (1 commit). Wired the DEV-LEDGER, added session protocol to AGENTS.md, created project-local CLAUDE.md, deleted stale `codex/port-atocore-ops-client` remote branch. No code changes, no redeploy needed.
|
||||
- **2026-04-11 Claude** `c5bad99..b3253f3` (11 commits + 1 merge). Length-aware reinforcement, project memories in pack, query-relevance memory ranking, hyphenated-identifier tokenizer, retrieval eval harness seeded, off-host backup wired end-to-end, docs synced, codex integration-pass branch merged. Harness went 0->6/6 on live Dalidou.
|
||||
- **2026-04-11 Codex (async review)** identified 2 P1s against a stale checkout. R1 was fair (extraction not automated), R2 was outdated (project memories already landed on main). Delivered the 8-day execution plan now in Active Plan.
|
||||
- **2026-04-06 Antoine** created `codex/atocore-integration-pass` with the `t420-openclaw/` workspace (merged 2026-04-11).
|
||||
|
||||
## Working Rules
|
||||
|
||||
- Claude builds; Codex audits. No parallel work on the same files.
|
||||
- Codex branches fork from `main`: `git fetch origin && git checkout -b codex/<topic> origin/main`.
|
||||
- P1 findings block further main commits until acknowledged in Open Review Findings.
|
||||
- Every session appends at least one Session Log line and bumps Orientation.
|
||||
- Trim Session Log and Recent Decisions to the last 20 at session end.
|
||||
- Docs in `docs/` may overclaim stale status; the ledger is the one-file source of truth for "what is true right now."
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Check live state
|
||||
ssh papa@dalidou "curl -s http://localhost:8100/health"
|
||||
|
||||
# Run the retrieval harness
|
||||
python scripts/retrieval_eval.py # human-readable
|
||||
python scripts/retrieval_eval.py --json # machine-readable
|
||||
|
||||
# Deploy a new main tip
|
||||
git push origin main && ssh papa@dalidou "bash /srv/storage/atocore/app/deploy/dalidou/deploy.sh"
|
||||
|
||||
# Reflection-loop ops
|
||||
python scripts/atocore_client.py batch-extract '' '' 200 false # preview
|
||||
python scripts/atocore_client.py batch-extract '' '' 200 true # persist
|
||||
python scripts/atocore_client.py triage
|
||||
```
|
||||
69
deploy/dalidou/batch-extract.sh
Normal file
69
deploy/dalidou/batch-extract.sh
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy/dalidou/batch-extract.sh
|
||||
# --------------------------------
|
||||
# Host-side LLM batch extraction for Dalidou.
|
||||
#
|
||||
# The claude CLI is available on the Dalidou HOST but NOT inside the
|
||||
# Docker container. This script runs on the host, fetches recent
|
||||
# interactions from the AtoCore API, runs the LLM extractor locally
|
||||
# (claude -p sonnet), and posts candidates back to the API.
|
||||
#
|
||||
# Intended to be called from cron-backup.sh after backup/cleanup/rsync,
|
||||
# or manually via:
|
||||
#
|
||||
# bash /srv/storage/atocore/app/deploy/dalidou/batch-extract.sh
|
||||
#
|
||||
# Environment variables:
|
||||
# ATOCORE_URL default http://127.0.0.1:8100
|
||||
# ATOCORE_EXTRACT_LIMIT default 50
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}"
|
||||
LIMIT="${ATOCORE_EXTRACT_LIMIT:-50}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
log() { printf '[%s] %s\n' "$TIMESTAMP" "$*"; }
|
||||
|
||||
# The Python script needs the atocore source on PYTHONPATH
|
||||
export PYTHONPATH="$APP_DIR/src:${PYTHONPATH:-}"
|
||||
|
||||
log "=== AtoCore batch extraction + triage starting ==="
|
||||
log "URL=$ATOCORE_URL LIMIT=$LIMIT"
|
||||
|
||||
# Step A: Extract candidates from recent interactions
|
||||
log "Step A: LLM extraction"
|
||||
python3 "$APP_DIR/scripts/batch_llm_extract_live.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
--limit "$LIMIT" \
|
||||
2>&1 || {
|
||||
log "WARN: batch extraction failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step B: Auto-triage candidates in the queue
|
||||
log "Step B: auto-triage"
|
||||
python3 "$APP_DIR/scripts/auto_triage.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1 || {
|
||||
log "WARN: auto-triage failed (non-blocking)"
|
||||
}
|
||||
|
||||
# Step C: Weekly synthesis (Sundays only)
|
||||
if [[ "$(date -u +%u)" == "7" ]]; then
|
||||
log "Step C: weekly project synthesis"
|
||||
python3 "$APP_DIR/scripts/synthesize_projects.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1 || {
|
||||
log "WARN: synthesis failed (non-blocking)"
|
||||
}
|
||||
|
||||
log "Step D: weekly lint pass"
|
||||
python3 "$APP_DIR/scripts/lint_knowledge_base.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1 || true
|
||||
fi
|
||||
|
||||
log "=== AtoCore batch extraction + triage complete ==="
|
||||
129
deploy/dalidou/cron-backup.sh
Executable file
129
deploy/dalidou/cron-backup.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy/dalidou/cron-backup.sh
|
||||
# ------------------------------
|
||||
# Daily backup + retention cleanup via the AtoCore API.
|
||||
#
|
||||
# Intended to run from cron on Dalidou:
|
||||
#
|
||||
# # Daily at 03:00 UTC
|
||||
# 0 3 * * * /srv/storage/atocore/app/deploy/dalidou/cron-backup.sh >> /var/log/atocore-backup.log 2>&1
|
||||
#
|
||||
# What it does:
|
||||
# 1. Creates a runtime backup (db + registry, no chroma by default)
|
||||
# 2. Runs retention cleanup with --confirm to delete old snapshots
|
||||
# 3. Logs results to stdout (captured by cron into the log file)
|
||||
#
|
||||
# Fail-open: exits 0 even on API errors so cron doesn't send noise
|
||||
# emails. Check /var/log/atocore-backup.log for diagnostics.
|
||||
#
|
||||
# Environment variables:
|
||||
# ATOCORE_URL default http://127.0.0.1:8100
|
||||
# ATOCORE_BACKUP_CHROMA default false (set to "true" for cold chroma copy)
|
||||
# ATOCORE_BACKUP_DIR default /srv/storage/atocore/backups
|
||||
# ATOCORE_BACKUP_RSYNC optional rsync destination for off-host copies
|
||||
# (e.g. papa@laptop:/home/papa/atocore-backups/)
|
||||
# When set, the local snapshots tree is rsynced to
|
||||
# the destination after cleanup. Unset = skip.
|
||||
# SSH key auth must already be configured from this
|
||||
# host to the destination.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}"
|
||||
INCLUDE_CHROMA="${ATOCORE_BACKUP_CHROMA:-false}"
|
||||
BACKUP_DIR="${ATOCORE_BACKUP_DIR:-/srv/storage/atocore/backups}"
|
||||
RSYNC_TARGET="${ATOCORE_BACKUP_RSYNC:-}"
|
||||
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
log() { printf '[%s] %s\n' "$TIMESTAMP" "$*"; }
|
||||
|
||||
log "=== AtoCore daily backup starting ==="
|
||||
|
||||
# Step 1: Create backup
|
||||
log "Step 1: creating backup (chroma=$INCLUDE_CHROMA)"
|
||||
BACKUP_RESULT=$(curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"include_chroma\": $INCLUDE_CHROMA}" \
|
||||
"$ATOCORE_URL/admin/backup" 2>&1) || {
|
||||
log "ERROR: backup creation failed: $BACKUP_RESULT"
|
||||
exit 0
|
||||
}
|
||||
log "Backup created: $BACKUP_RESULT"
|
||||
|
||||
# Step 2: Retention cleanup (confirm=true to actually delete)
|
||||
log "Step 2: running retention cleanup"
|
||||
CLEANUP_RESULT=$(curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"confirm": true}' \
|
||||
"$ATOCORE_URL/admin/backup/cleanup" 2>&1) || {
|
||||
log "ERROR: cleanup failed: $CLEANUP_RESULT"
|
||||
exit 0
|
||||
}
|
||||
log "Cleanup result: $CLEANUP_RESULT"
|
||||
|
||||
# Step 3: Off-host rsync (optional). Fail-open: log but don't abort
|
||||
# the cron so a laptop being offline at 03:00 UTC never turns the
|
||||
# local backup path red.
|
||||
if [[ -n "$RSYNC_TARGET" ]]; then
|
||||
log "Step 3: rsyncing snapshots to $RSYNC_TARGET"
|
||||
if [[ ! -d "$BACKUP_DIR/snapshots" ]]; then
|
||||
log "WARN: $BACKUP_DIR/snapshots does not exist, skipping rsync"
|
||||
else
|
||||
RSYNC_OUTPUT=$(rsync -a --delete \
|
||||
-e "ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new" \
|
||||
"$BACKUP_DIR/snapshots/" "$RSYNC_TARGET" 2>&1) && {
|
||||
log "Rsync complete"
|
||||
} || {
|
||||
log "WARN: rsync to $RSYNC_TARGET failed (offline or auth?): $RSYNC_OUTPUT"
|
||||
}
|
||||
fi
|
||||
else
|
||||
log "Step 3: ATOCORE_BACKUP_RSYNC not set, skipping off-host copy"
|
||||
fi
|
||||
|
||||
# Step 3a: Pull OpenClaw state from clawdbot (one-way import of
|
||||
# SOUL.md, USER.md, MODEL-ROUTING.md, MEMORY.md, recent memory/*.md).
|
||||
# Loose coupling: OpenClaw's internals don't need to change.
|
||||
# Fail-open: importer failure never blocks the pipeline.
|
||||
log "Step 3a: pull OpenClaw state"
|
||||
OPENCLAW_IMPORT="${ATOCORE_OPENCLAW_IMPORT:-true}"
|
||||
if [[ "$OPENCLAW_IMPORT" == "true" ]]; then
|
||||
python3 "$SCRIPT_DIR/../../scripts/import_openclaw_state.py" \
|
||||
--base-url "$ATOCORE_URL" \
|
||||
2>&1 | while IFS= read -r line; do log " $line"; done || {
|
||||
log " WARN: OpenClaw import failed (non-blocking)"
|
||||
}
|
||||
else
|
||||
log " skipped (ATOCORE_OPENCLAW_IMPORT != true)"
|
||||
fi
|
||||
|
||||
# Step 3b: Auto-refresh vault sources so new PKM files flow in
|
||||
# automatically. Fail-open: never blocks the rest of the pipeline.
|
||||
log "Step 3b: auto-refresh vault sources"
|
||||
REFRESH_RESULT=$(curl -sf -X POST --max-time 600 \
|
||||
"$ATOCORE_URL/ingest/sources" 2>&1) && {
|
||||
log "Sources refresh complete"
|
||||
} || {
|
||||
log "WARN: sources refresh failed (non-blocking): $REFRESH_RESULT"
|
||||
}
|
||||
|
||||
# Step 4: Batch LLM extraction on recent interactions (optional).
|
||||
# Runs HOST-SIDE because claude CLI is on the host, not inside the
|
||||
# Docker container. The script fetches interactions from the API,
|
||||
# runs claude -p locally, and POSTs candidates back.
|
||||
# Fail-open: extraction failure never blocks backup.
|
||||
EXTRACT="${ATOCORE_EXTRACT_BATCH:-true}"
|
||||
if [[ "$EXTRACT" == "true" ]]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
log "Step 4: running host-side batch LLM extraction"
|
||||
bash "$SCRIPT_DIR/batch-extract.sh" 2>&1 && {
|
||||
log "Extraction complete"
|
||||
} || {
|
||||
log "WARN: batch extraction failed (this is non-blocking)"
|
||||
}
|
||||
else
|
||||
log "Step 4: ATOCORE_EXTRACT_BATCH not set to true, skipping extraction"
|
||||
fi
|
||||
|
||||
log "=== AtoCore daily backup complete ==="
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
Reads the Stop hook JSON from stdin, extracts the last user prompt
|
||||
from the transcript JSONL, and POSTs to the AtoCore /interactions
|
||||
endpoint in conservative mode (reinforce=false, no extraction).
|
||||
endpoint with reinforcement enabled (no extraction).
|
||||
|
||||
Fail-open: always exits 0, logs errors to stderr only.
|
||||
|
||||
@@ -81,7 +81,7 @@ def _capture() -> None:
|
||||
"client": "claude-code",
|
||||
"session_id": session_id,
|
||||
"project": project,
|
||||
"reinforce": False,
|
||||
"reinforce": True,
|
||||
}
|
||||
|
||||
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
||||
|
||||
206
docs/architecture/knowledge-architecture.md
Normal file
206
docs/architecture/knowledge-architecture.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# AtoCore Knowledge Architecture
|
||||
|
||||
## The Problem
|
||||
|
||||
Engineering work produces two kinds of knowledge simultaneously:
|
||||
|
||||
1. **Applied knowledge** — specific to the project being worked on
|
||||
("the p04 support pad layout is driven by CTE gradient analysis")
|
||||
2. **Domain knowledge** — generalizable insight earned through that work
|
||||
("Zerodur CTE gradient dominates WFE at fast focal ratios")
|
||||
|
||||
A system that only stores applied knowledge loses the general insight.
|
||||
A system that mixes them pollutes project context with cross-project
|
||||
noise. AtoCore needs both — separated, but both growing organically
|
||||
from the same conversations.
|
||||
|
||||
## The Quality Bar
|
||||
|
||||
**AtoCore stores earned insight, not information.**
|
||||
|
||||
The test: "Would a competent engineer need experience to know this,
|
||||
or could they find it in 30 seconds?"
|
||||
|
||||
| Store | Don't store |
|
||||
|-------|-------------|
|
||||
| "Preston removal model breaks down below 5N because the contact assumption fails" | "Preston's equation relates removal rate to pressure and velocity" |
|
||||
| "m=1 (coma) is NOT correctable by force modulation (score 0.09)" | "Zernike polynomials describe wavefront aberrations" |
|
||||
| "At F/1.2, CTE gradient costs ~3nm WFE and drives pad placement" | "Zerodur CTE is 0.05 ppm/K" |
|
||||
| "Quilting limit for 16-inch tool is 234N" | "Quilting is a mid-spatial-frequency artifact in polishing" |
|
||||
|
||||
The bar is enforced in the LLM extraction system prompt
|
||||
(`src/atocore/memory/extractor_llm.py`) and the auto-triage prompt
|
||||
(`scripts/auto_triage.py`). Both explicitly list examples of what
|
||||
qualifies and what doesn't.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Five-tier context assembly
|
||||
|
||||
When AtoCore builds a context pack for any LLM query, it assembles
|
||||
five tiers in strict trust order:
|
||||
|
||||
```
|
||||
Tier 1: Trusted Project State [project-specific, highest trust]
|
||||
Curated key-value entries from the project state API.
|
||||
Example: "decision/vendor_path: Twyman-Green preferred, 4D
|
||||
technical lead but cost-challenged"
|
||||
|
||||
Tier 2: Identity / Preferences [global, always included]
|
||||
Who the user is and how they work.
|
||||
Example: "Antoine Letarte, mechanical/optical engineer at
|
||||
Atomaste" / "No API keys — uses OAuth exclusively"
|
||||
|
||||
Tier 3: Project Memories [project-specific]
|
||||
Reinforced memories from the reflection loop, scoped to the
|
||||
queried project. Example: "Firmware interface contract is
|
||||
invariant: controller-job.v1 in, run-log.v1 out"
|
||||
|
||||
Tier 4: Domain Knowledge [cross-project]
|
||||
Earned engineering insight with project="" and a domain tag.
|
||||
Surfaces in ALL project packs when query-relevant.
|
||||
Example: "[materials] Zerodur CTE gradient dominates WFE at
|
||||
fast focal ratios — costs ~3nm at F/1.2"
|
||||
|
||||
Tier 5: Retrieved Chunks [project-boosted, lowest trust]
|
||||
Vector-similarity search over the ingested document corpus.
|
||||
Project-hinted but not filtered — cross-project docs can
|
||||
appear at lower rank.
|
||||
```
|
||||
|
||||
### Budget allocation (at default 3000 chars)
|
||||
|
||||
| Tier | Budget ratio | Approx chars | Entries |
|
||||
|------|-------------|-------------|---------|
|
||||
| Project State | 20% | 600 | all curated entries |
|
||||
| Identity/Preferences | 5% | 150 | 1 memory |
|
||||
| Project Memories | 25% | 750 | 2-3 memories |
|
||||
| Domain Knowledge | 10% | 300 | 1-2 memories |
|
||||
| Retrieved Chunks | 40% | 1200 | 2-4 chunks |
|
||||
|
||||
Trim order when budget is tight: chunks first, then domain knowledge,
|
||||
then project memories, then identity, then project state last.
|
||||
|
||||
### Knowledge domains
|
||||
|
||||
The LLM extractor tags domain knowledge with one of these domains:
|
||||
|
||||
| Domain | What qualifies |
|
||||
|--------|---------------|
|
||||
| `physics` | Optical physics, wave propagation, diffraction, thermal effects |
|
||||
| `materials` | Material properties in context, CTE behavior, stress limits |
|
||||
| `optics` | Lens/mirror design, aberration analysis, metrology techniques |
|
||||
| `mechanics` | Structural FEA insights, support system design, kinematics |
|
||||
| `manufacturing` | Polishing, grinding, machining, process control |
|
||||
| `metrology` | Measurement systems, interferometry, calibration techniques |
|
||||
| `controls` | PID tuning, force control, servo systems, real-time constraints |
|
||||
| `software` | Architecture patterns, testing strategies, deployment insights |
|
||||
| `math` | Numerical methods, optimization, statistical analysis |
|
||||
| `finance` | Cost modeling, procurement strategy, budget optimization |
|
||||
|
||||
New domains can be added by updating the system prompt in
|
||||
`extractor_llm.py` and `batch_llm_extract_live.py`.
|
||||
|
||||
### How domain knowledge is stored
|
||||
|
||||
Domain tags are embedded as a prefix in the memory content:
|
||||
|
||||
```
|
||||
memory_type: knowledge
|
||||
project: "" ← empty = cross-project
|
||||
content: "[materials] Zerodur CTE gradient dominates WFE at F/1.2"
|
||||
```
|
||||
|
||||
The `[domain]` prefix is a lightweight encoding that avoids a schema
|
||||
migration. The context builder's query-relevance ranking matches on
|
||||
domain terms naturally (a query about "materials" or "CTE" will rank
|
||||
a `[materials]` memory higher). A future migration can parse the
|
||||
prefix into a proper `domain` column.
|
||||
|
||||
## How knowledge flows
|
||||
|
||||
### Capture → Extract → Triage → Surface
|
||||
|
||||
```
|
||||
1. CAPTURE
|
||||
Claude Code (Stop hook) or OpenClaw (plugin)
|
||||
→ POST /interactions with reinforce=true
|
||||
→ Interaction stored on Dalidou
|
||||
|
||||
2. EXTRACT (nightly cron, 03:00 UTC)
|
||||
batch_llm_extract_live.py runs claude -p sonnet
|
||||
→ For each interaction, the LLM decides:
|
||||
- Is this project-specific? → candidate with project=X
|
||||
- Is this generalizable insight? → candidate with domain=Y, project=""
|
||||
- Is it both? → TWO candidates emitted
|
||||
- Is it common knowledge? → skip (quality bar)
|
||||
→ Candidates persisted as status=candidate
|
||||
|
||||
3. TRIAGE (nightly, immediately after extraction)
|
||||
auto_triage.py runs claude -p sonnet
|
||||
→ Each candidate classified: promote / reject / needs_human
|
||||
→ Auto-promote at confidence ≥ 0.8 + no duplicate
|
||||
→ Auto-reject stale snapshots, duplicates, common knowledge
|
||||
→ Only needs_human reaches the operator
|
||||
|
||||
4. SURFACE (every context/build query)
|
||||
→ Project-specific memories appear in Tier 3
|
||||
→ Domain knowledge appears in Tier 4 (regardless of project)
|
||||
→ Both are query-ranked by overlap-density
|
||||
```
|
||||
|
||||
### Example: knowledge earned on p04 surfaces on p06
|
||||
|
||||
Working on p04-gigabit, you discover that Zerodur CTE gradient is
|
||||
the dominant WFE contributor at fast focal ratios. The extraction
|
||||
produces:
|
||||
|
||||
```json
|
||||
[
|
||||
{"type": "project", "content": "CTE gradient analysis drove the
|
||||
M1 support pad layout — 2nd largest WFE contributor after gravity",
|
||||
"project": "p04-gigabit", "domain": "", "confidence": 0.6},
|
||||
|
||||
{"type": "knowledge", "content": "Zerodur CTE gradient dominates
|
||||
WFE contribution at fast focal ratios (F/1.2 = ~3nm)",
|
||||
"project": "", "domain": "materials", "confidence": 0.6}
|
||||
]
|
||||
```
|
||||
|
||||
Two weeks later, working on p06-polisher (which also uses Zerodur):
|
||||
|
||||
```
|
||||
Query: "thermal effects on polishing accuracy"
|
||||
Project: p06-polisher
|
||||
|
||||
Tier 3 (Project Memories):
|
||||
[project] Calibration loop adjusts Preston kp from surface measurements...
|
||||
|
||||
Tier 4 (Domain Knowledge):
|
||||
[materials] Zerodur CTE gradient dominates WFE contribution at fast
|
||||
focal ratios — THIS CAME FROM P04 WORK
|
||||
```
|
||||
|
||||
The insight crosses over without any manual curation.
|
||||
|
||||
## Future directions
|
||||
|
||||
### Personal knowledge branch
|
||||
The same architecture supports personal domains (health, finance,
|
||||
personal) by adding new domain tags and a trust boundary so
|
||||
Atomaste project data never leaks into personal packs. The domain
|
||||
system is domain-agnostic — it doesn't care whether the domain is
|
||||
"optics" or "nutrition".
|
||||
|
||||
### Multi-model extraction
|
||||
Different models can specialize: sonnet for extraction, opus or
|
||||
Gemini for triage review. Independent validation reduces correlated
|
||||
blind spots on what qualifies as "earned insight" vs "common
|
||||
knowledge."
|
||||
|
||||
### Reinforcement-based domain promotion
|
||||
A domain-knowledge memory that gets reinforced across multiple
|
||||
projects (its content echoed in p04, p05, and p06 responses)
|
||||
accumulates confidence faster than a project-specific memory.
|
||||
High-confidence domain memories could auto-promote to a "verified
|
||||
knowledge" tier above regular domain knowledge.
|
||||
@@ -24,15 +24,40 @@ read-only additive mode.
|
||||
- Phase 5 - Project State
|
||||
- Phase 7 - Context Builder
|
||||
|
||||
### Partial
|
||||
### Baseline Complete
|
||||
|
||||
- Phase 4 - Identity / Preferences
|
||||
- Phase 8 - OpenClaw Integration
|
||||
- Phase 4 - Identity / Preferences. As of 2026-04-12: 3 identity
|
||||
memories (role, projects, infrastructure) and 3 preference memories
|
||||
(no API keys, multi-model collab, action-over-discussion) seeded
|
||||
on live Dalidou. Identity/preference band surfaces in context packs
|
||||
at 5% budget ratio. Future identity/preference extraction happens
|
||||
organically via the nightly LLM extraction pipeline.
|
||||
|
||||
- Phase 8 - OpenClaw Integration. As of 2026-04-12 the T420 OpenClaw
|
||||
helper (`t420-openclaw/atocore.py`) is verified end-to-end against
|
||||
live Dalidou: health check, auto-context with project detection,
|
||||
Trusted Project State surfacing, project-memory band, fail-open on
|
||||
unreachable host. Tested from both the development machine and the
|
||||
T420 via SSH. The helper covers 15 of the 33 API endpoints — the
|
||||
excluded endpoints (memory management, interactions, backup) are
|
||||
correctly scoped to the operator client (`scripts/atocore_client.py`)
|
||||
per the read-only additive integration model.
|
||||
|
||||
### Baseline Complete
|
||||
|
||||
- Phase 9 - Reflection (all three foundation commits landed:
|
||||
A capture, B reinforcement, C candidate extraction + review queue)
|
||||
A capture, B reinforcement, C candidate extraction + review queue).
|
||||
As of 2026-04-11 the capture → reinforce half runs automatically on
|
||||
every Stop-hook capture (length-aware token-overlap matcher handles
|
||||
paragraph-length memories), and project-scoped memories now reach
|
||||
the context pack via a dedicated `--- Project Memories ---` band
|
||||
between identity/preference and retrieved chunks. The extract half
|
||||
is still a manual / batch flow by design (`scripts/atocore_client.py
|
||||
batch-extract` + `triage`). First live batch-extract run over 42
|
||||
captured interactions produced 1 candidate (rule extractor is
|
||||
conservative and keys on structural cues like `## Decision:`
|
||||
headings that rarely appear in conversational LLM responses) —
|
||||
extractor tuning is a known follow-up.
|
||||
|
||||
### Not Yet Complete In The Intended Sense
|
||||
|
||||
@@ -95,59 +120,52 @@ This sits implicitly between Phase 8 (OpenClaw) and Phase 11
|
||||
(multi-model). Memory-review and engineering-entity commands are
|
||||
deferred from the shared client until their workflows are exercised.
|
||||
|
||||
## What Is Real Today
|
||||
## What Is Real Today (updated 2026-04-12)
|
||||
|
||||
- canonical AtoCore runtime on Dalidou
|
||||
- canonical machine DB and vector store on Dalidou
|
||||
- project registry with:
|
||||
- template
|
||||
- proposal preview
|
||||
- register
|
||||
- update
|
||||
- refresh
|
||||
- read-only additive OpenClaw helper on the T420
|
||||
- seeded project corpus for:
|
||||
- `p04-gigabit`
|
||||
- `p05-interferometer`
|
||||
- `p06-polisher`
|
||||
- conservative Trusted Project State for those active projects
|
||||
- first operational backup foundation for SQLite + project registry
|
||||
- implementation-facing architecture notes for future engineering knowledge work
|
||||
- first organic routing layer in OpenClaw via:
|
||||
- `detect-project`
|
||||
- `auto-context`
|
||||
- canonical AtoCore runtime on Dalidou (build_sha tracked, deploy.sh verified)
|
||||
- 33,253 vectors across 5 registered projects
|
||||
- project registry with template, proposal, register, update, refresh
|
||||
- 5 registered projects:
|
||||
- `p04-gigabit` (483 docs, 5 state entries)
|
||||
- `p05-interferometer` (109 docs, 9 state entries)
|
||||
- `p06-polisher` (564 docs, 9 state entries)
|
||||
- `atomizer-v2` (568 docs, newly ingested 2026-04-12)
|
||||
- `atocore` (drive source, 38 state entries)
|
||||
- 47 active memories (16 project, 16 knowledge, 6 adaptation, 3 identity, 3 preference, 3 episodic)
|
||||
- context pack assembly with 4 tiers: Trusted Project State > identity/preference > project memories > retrieved chunks
|
||||
- query-relevance memory ranking with overlap-density scoring
|
||||
- retrieval eval harness: 18 fixtures, 17/18 passing
|
||||
- 290 tests passing
|
||||
- nightly pipeline: backup → cleanup → rsync → LLM extraction (sonnet) → auto-triage
|
||||
- off-host backup to clawdbot (T420) via rsync
|
||||
- both Claude Code and OpenClaw capture interactions to AtoCore
|
||||
- DEV-LEDGER.md as shared operating memory between Claude and Codex
|
||||
- observability dashboard at GET /admin/dashboard
|
||||
|
||||
## Now
|
||||
|
||||
These are the current practical priorities.
|
||||
|
||||
1. Finish practical OpenClaw integration
|
||||
- make the helper lifecycle feel natural in daily use
|
||||
- use the new organic routing layer for project-knowledge questions
|
||||
- confirm fail-open behavior remains acceptable
|
||||
- keep AtoCore clearly additive
|
||||
2. Tighten retrieval quality
|
||||
- reduce cross-project competition
|
||||
- improve ranking on short or ambiguous prompts
|
||||
- add only a few anchor docs where retrieval is still weak
|
||||
3. Continue controlled ingestion
|
||||
- deepen active projects selectively
|
||||
- avoid noisy bulk corpus growth
|
||||
4. Strengthen operational boringness
|
||||
- backup and restore procedure
|
||||
- Chroma rebuild / backup policy
|
||||
- retention and restore validation
|
||||
1. **Observe and stabilize** — let the nightly pipeline run for a week,
|
||||
check the dashboard daily, verify memories accumulate correctly
|
||||
from organic Claude Code and OpenClaw use
|
||||
2. **Multi-model triage** (Phase 11 entry) — switch auto-triage to a
|
||||
different model than the extractor for independent validation
|
||||
3. **Automated eval in cron** (Phase 12 entry) — add retrieval harness
|
||||
to the nightly cron so regressions are caught automatically
|
||||
4. **Atomizer-v2 state entries** — curate Trusted Project State for the
|
||||
newly ingested Atomizer knowledge base
|
||||
|
||||
## Next
|
||||
|
||||
These are the next major layers after the current practical pass.
|
||||
These are the next major layers after the current stabilization pass.
|
||||
|
||||
1. Clarify AtoDrive as a real operational truth layer
|
||||
2. Mature identity / preferences handling
|
||||
3. Improve observability for:
|
||||
- retrieval quality
|
||||
- context-pack inspection
|
||||
- comparison of behavior with and without AtoCore
|
||||
1. Phase 10 Write-back — confidence-based auto-promotion from
|
||||
reinforcement signal (a memory reinforced N times auto-promotes)
|
||||
2. Phase 6 AtoDrive — clarify Google Drive as a trusted operational
|
||||
source and ingest from it
|
||||
3. Phase 13 Hardening — Chroma backup policy, monitoring, alerting,
|
||||
failure visibility beyond log files
|
||||
|
||||
## Later
|
||||
|
||||
@@ -165,9 +183,16 @@ direction, but not yet ready for immediate implementation.
|
||||
|
||||
These remain intentionally deferred.
|
||||
|
||||
- automatic write-back from OpenClaw into AtoCore
|
||||
- automatic memory promotion
|
||||
- reflection loop integration
|
||||
- ~~automatic write-back from OpenClaw into AtoCore~~ — OpenClaw capture
|
||||
plugin now exists (`openclaw-plugins/atocore-capture/`), interactions
|
||||
flow. Write-back of promoted memories back to OpenClaw's own memory
|
||||
system is still deferred.
|
||||
- ~~automatic memory promotion~~ — auto-triage now handles promote/reject
|
||||
for extraction candidates. Reinforcement-based auto-promotion
|
||||
(Phase 10) is the remaining piece.
|
||||
- ~~reflection loop integration~~ — fully operational: capture (both
|
||||
clients) → reinforce (automatic) → extract (nightly cron, sonnet) →
|
||||
auto-triage (nightly, sonnet) → only needs_human reaches the user.
|
||||
- replacing OpenClaw's own memory system
|
||||
- live machine-DB sync between machines
|
||||
- full ontology / graph expansion before the current baseline is stable
|
||||
|
||||
@@ -137,7 +137,12 @@ P06:
|
||||
|
||||
- automatic write-back from OpenClaw into AtoCore
|
||||
- automatic memory promotion
|
||||
- reflection loop integration
|
||||
- ~~reflection loop integration~~ — baseline now landed (2026-04-11):
|
||||
Stop hook runs reinforce automatically, project memories are folded
|
||||
into the context pack, batch-extract and triage CLIs exist. What
|
||||
remains deferred: scheduled/automatic batch extraction and extractor
|
||||
rule tuning (rule-based extractor produced 1 candidate from 42 real
|
||||
captures — needs new cues for conversational LLM content).
|
||||
- replacing OpenClaw's own memory system
|
||||
- syncing the live machine DB between machines
|
||||
|
||||
@@ -159,6 +164,116 @@ The next batch is successful if:
|
||||
- project ingestion remains controlled rather than noisy
|
||||
- the canonical Dalidou instance stays stable
|
||||
|
||||
## Retrieval Quality Review — 2026-04-11
|
||||
|
||||
First sweep with real project-hinted queries on Dalidou. Used
|
||||
`POST /context/build` against p04, p05, p06 with representative
|
||||
questions and inspected `formatted_context`.
|
||||
|
||||
Findings:
|
||||
|
||||
- **Trusted Project State is surfacing correctly.** The DECISION and
|
||||
REQUIREMENT categories appear at the top of the pack and include
|
||||
the expected key facts (e.g. p04 "Option B conical-back mirror
|
||||
architecture"). This is the strongest signal in the pack today.
|
||||
- **Chunk retrieval is relevant on-topic but broad.** Top chunks for
|
||||
the p04 architecture query are PDR intro, CAD assembly overview,
|
||||
and the index — all on the right project but none of them directly
|
||||
answer the "why was Option B chosen" question. The authoritative
|
||||
answer sits in Project State, not in the chunks.
|
||||
- **Active memories are NOT reaching the pack.** The context builder
|
||||
surfaces Trusted Project State and retrieved chunks but does not
|
||||
include the 21 active project/knowledge memories. Reinforcement
|
||||
(Phase 9 Commit B) bumps memory confidence without the memory ever
|
||||
being read back into a prompt — the reflection loop has no outlet
|
||||
on the retrieval side. This is a design gap, not a bug: needs a
|
||||
decision on whether memories should feed into context assembly,
|
||||
and if so at what trust level (below project_state, above chunks).
|
||||
- **Cross-project bleed is low.** The p04 query did pull one p05
|
||||
chunk (CGH_Design_Input_for_AOM) as the bottom hit but the top-4
|
||||
were all p04.
|
||||
|
||||
Proposed follow-ups (not yet scheduled):
|
||||
|
||||
1. ~~Decide whether memories should be folded into `formatted_context`
|
||||
and under what section header.~~ DONE 2026-04-11 (commits 8ea53f4,
|
||||
5913da5, 1161645). A `--- Project Memories ---` band now sits
|
||||
between identity/preference and retrieved chunks, gated on a
|
||||
canonical project hint to prevent cross-project bleed. Budget
|
||||
ratio 0.25 (tuned empirically — paragraph memories are ~400 chars
|
||||
and earlier 0.15 ratio starved the first entry by one char).
|
||||
Verified live: p04 architecture query surfaces the Option B memory.
|
||||
2. Re-run the same three queries after any builder change and compare
|
||||
`formatted_context` diffs — still open, and is the natural entry
|
||||
point for the retrieval eval harness on the roadmap.
|
||||
|
||||
## Reflection Loop Live Check — 2026-04-11
|
||||
|
||||
First real run of `batch-extract` across 42 captured Claude Code
|
||||
interactions on Dalidou produced exactly **1 candidate**, and that
|
||||
candidate was a synthetic test capture from earlier in the session
|
||||
(rejected). Finding:
|
||||
|
||||
- The rule-based extractor in `src/atocore/memory/extractor.py` keys
|
||||
on explicit structural cues (decision headings like
|
||||
`## Decision: ...`, preference sentences, etc.). Real Claude Code
|
||||
responses are conversational and almost never contain those cues.
|
||||
- This means the capture → extract half of the reflection loop is
|
||||
effectively inert against organic LLM sessions until either the
|
||||
rules are broadened (new cue families: "we chose X because...",
|
||||
"the selected approach is...", etc.) or an LLM-assisted extraction
|
||||
path is added alongside the rule-based one.
|
||||
- Capture → reinforce is working correctly on live data (length-aware
|
||||
matcher verified on live paraphrase of a p04 memory).
|
||||
|
||||
Follow-up candidates:
|
||||
|
||||
1. ~~Extractor rule expansion~~ — Day 2 baseline showed 0% recall
|
||||
across 5 distinct miss classes; rule expansion cannot close a
|
||||
5-way miss. Deprioritized.
|
||||
2. ~~LLM-assisted extractor~~ — DONE 2026-04-12. `extractor_llm.py`
|
||||
shells out to `claude -p` (Haiku, OAuth, no API key). First live
|
||||
run: 100% recall, 2.55 yield/interaction on a 20-interaction
|
||||
labeled set. First triage: 51 candidates → 16 promoted, 35
|
||||
rejected (31% accept rate). Active memories 20 → 36.
|
||||
3. ~~Retrieval eval harness~~ — DONE 2026-04-11 (scripts/retrieval_eval.py,
|
||||
6/6 passing). Expansion to 15-20 fixtures is mini-phase Day 6.
|
||||
|
||||
## Extractor Scope — 2026-04-12
|
||||
|
||||
What the LLM-assisted extractor (`src/atocore/memory/extractor_llm.py`)
|
||||
extracts from conversational Claude Code captures:
|
||||
|
||||
**In scope:**
|
||||
|
||||
- Architectural commitments (e.g. "Z-axis is engage/retract, not
|
||||
continuous position")
|
||||
- Ratified decisions with project scope (e.g. "USB SSD mandatory on
|
||||
RPi for telemetry storage")
|
||||
- Durable engineering facts (e.g. "telemetry data rate ~29 MB/hour")
|
||||
- Working rules and adaptation patterns (e.g. "extraction stays off
|
||||
the capture hot path")
|
||||
- Interface invariants (e.g. "controller-job.v1 in, run-log.v1 out;
|
||||
no firmware change needed")
|
||||
|
||||
**Out of scope (intentionally rejected by triage):**
|
||||
|
||||
- Transient roadmap / plan steps that will be stale in a week
|
||||
- Operational instructions ("run this command to deploy")
|
||||
- Process rules that live in DEV-LEDGER.md / AGENTS.md, not in memory
|
||||
- Implementation details that are too granular (individual field names
|
||||
when the parent concept is already captured)
|
||||
- Already-fixed review findings (P1/P2 that no longer apply)
|
||||
- Duplicates of existing active memories with wrong project tags
|
||||
|
||||
**Trust model:**
|
||||
|
||||
- Extraction stays off the capture hot path (batch / manual only)
|
||||
- All candidates land as `status=candidate`, never auto-promoted
|
||||
- Human or auto-triage reviews before promotion to active
|
||||
- Future direction: multi-model extraction + triage (Codex/Gemini as
|
||||
second-pass reviewers for robustness against single-model bias)
|
||||
|
||||
## Long-Run Goal
|
||||
|
||||
The long-run target is:
|
||||
|
||||
56
docs/openclaw-atocore-integration-proposal.md
Normal file
56
docs/openclaw-atocore-integration-proposal.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# OpenClaw -> AtoCore Integration Proposal
|
||||
|
||||
One-way pull is the right pattern.
|
||||
|
||||
**Stable surface to pull**
|
||||
- Durable files in the OpenClaw workspace:
|
||||
- `SOUL.md`
|
||||
- `USER.md`
|
||||
- `MODEL-ROUTING.md`
|
||||
- `MEMORY.md`
|
||||
- `memory/YYYY-MM-DD.md`
|
||||
- `memory/heartbeat-state.json`
|
||||
- `HEARTBEAT.md` only as operational state, not long-term truth
|
||||
- These are explicitly documented in `t420-openclaw/AGENTS.md` as the continuity layer OpenClaw reads every session.
|
||||
|
||||
**Volatile vs durable**
|
||||
- Durable:
|
||||
- `SOUL.md`, `USER.md`, `MODEL-ROUTING.md`, `MEMORY.md`
|
||||
- dated memory notes under `memory/`
|
||||
- explicit JSON state like `memory/heartbeat-state.json`
|
||||
- Volatile:
|
||||
- in-session context
|
||||
- ephemeral heartbeat work
|
||||
- transient orchestration state
|
||||
- platform response buffers
|
||||
- Semi-durable:
|
||||
- `HEARTBEAT.md` and operational notes; useful for importer hints, but not canonical identity/memory truth
|
||||
|
||||
**Formats**
|
||||
- Mostly Markdown
|
||||
- Some JSON (`heartbeat-state.json`)
|
||||
- No stable OpenClaw-local DB or API surface is visible in this snapshot
|
||||
|
||||
**How pull should work**
|
||||
- Start with cron-based filesystem reads, not an OpenClaw HTTP API.
|
||||
- Read the durable files on a schedule, hash them, and import only deltas.
|
||||
- Map them by type:
|
||||
- `SOUL.md` / `USER.md` -> identity/preferences review candidates
|
||||
- `MEMORY.md` -> curated long-term memory candidates
|
||||
- `memory/YYYY-MM-DD.md` -> interaction/episodic import stream
|
||||
- `heartbeat-state.json` -> low-priority ops metadata only if useful
|
||||
|
||||
**Discord**
|
||||
- I do not see a documented durable Discord message store in the OpenClaw workspace snapshot.
|
||||
- `AGENTS.md` references Discord behavior, but not a canonical local log/database.
|
||||
- Treat Discord as transient unless OpenClaw exposes an explicit export/log file later.
|
||||
|
||||
**Biggest risk**
|
||||
- Importing raw OpenClaw files as truth will blur curated memory and noisy session chatter.
|
||||
- Mitigation: importer should classify by source tier, preserve provenance, and default to candidate/episodic ingestion rather than active memory promotion.
|
||||
|
||||
**Recommendation**
|
||||
- Do not build two-way sync.
|
||||
- Do not require OpenClaw to change architecture.
|
||||
- Build one importer against the file continuity layer first.
|
||||
- Add a formal export surface later only if the importer becomes too heuristic.
|
||||
29
openclaw-plugins/atocore-capture/README.md
Normal file
29
openclaw-plugins/atocore-capture/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# AtoCore Capture Plugin for OpenClaw
|
||||
|
||||
Minimal OpenClaw plugin that mirrors Claude Code's `capture_stop.py` behavior:
|
||||
|
||||
- watches user-triggered assistant turns
|
||||
- POSTs `prompt` + `response` to `POST /interactions`
|
||||
- sets `client="openclaw"`
|
||||
- sets `reinforce=true`
|
||||
- fails open on network or API errors
|
||||
|
||||
## Config
|
||||
|
||||
Optional plugin config:
|
||||
|
||||
```json
|
||||
{
|
||||
"baseUrl": "http://dalidou:8100",
|
||||
"minPromptLength": 15,
|
||||
"maxResponseLength": 50000
|
||||
}
|
||||
```
|
||||
|
||||
If `baseUrl` is omitted, the plugin uses `ATOCORE_BASE_URL` or defaults to `http://dalidou:8100`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Project detection is intentionally left empty for now. Unscoped capture is acceptable because AtoCore's extraction pipeline handles unscoped interactions.
|
||||
- Extraction is **not** part of the capture path. This plugin only records interactions and lets AtoCore reinforcement run automatically.
|
||||
- The plugin captures only user-triggered turns, not heartbeats or system-only runs.
|
||||
94
openclaw-plugins/atocore-capture/index.js
Normal file
94
openclaw-plugins/atocore-capture/index.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const DEFAULT_BASE_URL = process.env.ATOCORE_BASE_URL || "http://dalidou:8100";
|
||||
const DEFAULT_MIN_PROMPT_LENGTH = 15;
|
||||
const DEFAULT_MAX_RESPONSE_LENGTH = 50_000;
|
||||
|
||||
function trimText(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function truncateResponse(text, maxLength) {
|
||||
if (!text || text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength)}\n\n[truncated]`;
|
||||
}
|
||||
|
||||
function shouldCapturePrompt(prompt, minLength) {
|
||||
const text = trimText(prompt);
|
||||
if (!text) return false;
|
||||
if (text.startsWith("<")) return false;
|
||||
return text.length >= minLength;
|
||||
}
|
||||
|
||||
async function postInteraction(baseUrl, payload, logger) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/interactions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger?.debug?.("atocore_capture_post_failed", { status: res.status });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger?.debug?.("atocore_capture_post_error", {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
register(api) {
|
||||
const logger = api.logger;
|
||||
const pendingBySession = new Map();
|
||||
|
||||
api.on("before_agent_start", async (event, ctx) => {
|
||||
if (ctx?.trigger && ctx.trigger !== "user") return;
|
||||
const config = api.getConfig?.() || {};
|
||||
const minPromptLength = Number(config.minPromptLength || DEFAULT_MIN_PROMPT_LENGTH);
|
||||
const prompt = trimText(event?.prompt || "");
|
||||
if (!shouldCapturePrompt(prompt, minPromptLength)) {
|
||||
pendingBySession.delete(ctx.sessionId);
|
||||
return;
|
||||
}
|
||||
pendingBySession.set(ctx.sessionId, {
|
||||
prompt,
|
||||
sessionId: ctx.sessionId,
|
||||
sessionKey: ctx.sessionKey || "",
|
||||
project: ""
|
||||
});
|
||||
});
|
||||
|
||||
api.on("llm_output", async (event, ctx) => {
|
||||
if (ctx?.trigger && ctx.trigger !== "user") return;
|
||||
const pending = pendingBySession.get(ctx.sessionId);
|
||||
if (!pending) return;
|
||||
|
||||
const assistantTexts = Array.isArray(event?.assistantTexts) ? event.assistantTexts : [];
|
||||
const response = truncateResponse(trimText(assistantTexts.join("\n\n")), Number((api.getConfig?.() || {}).maxResponseLength || DEFAULT_MAX_RESPONSE_LENGTH));
|
||||
if (!response) return;
|
||||
|
||||
const config = api.getConfig?.() || {};
|
||||
const baseUrl = trimText(config.baseUrl) || DEFAULT_BASE_URL;
|
||||
const payload = {
|
||||
prompt: pending.prompt,
|
||||
response,
|
||||
client: "openclaw",
|
||||
session_id: pending.sessionKey || pending.sessionId,
|
||||
project: pending.project || "",
|
||||
reinforce: true
|
||||
};
|
||||
|
||||
await postInteraction(baseUrl, payload, logger);
|
||||
pendingBySession.delete(ctx.sessionId);
|
||||
});
|
||||
|
||||
api.on("session_end", async (event) => {
|
||||
if (event?.sessionId) pendingBySession.delete(event.sessionId);
|
||||
});
|
||||
}
|
||||
});
|
||||
29
openclaw-plugins/atocore-capture/openclaw.plugin.json
Normal file
29
openclaw-plugins/atocore-capture/openclaw.plugin.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "atocore-capture",
|
||||
"name": "AtoCore Capture",
|
||||
"description": "Captures completed OpenClaw assistant turns to AtoCore interactions for reinforcement.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"description": "Override AtoCore base URL. Defaults to ATOCORE_BASE_URL or http://dalidou:8100"
|
||||
},
|
||||
"minPromptLength": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Minimum user prompt length required before capture"
|
||||
},
|
||||
"maxResponseLength": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"description": "Maximum assistant response length to store"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"uiHints": {
|
||||
"category": "automation",
|
||||
"displayName": "AtoCore Capture"
|
||||
}
|
||||
}
|
||||
7
openclaw-plugins/atocore-capture/package.json
Normal file
7
openclaw-plugins/atocore-capture/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@atomaste/atocore-openclaw-capture",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"description": "OpenClaw plugin that captures assistant turns to AtoCore interactions"
|
||||
}
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"pydantic>=2.6.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"structlog>=24.1.0",
|
||||
"markdown>=3.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -6,3 +6,4 @@ sentence-transformers>=2.5.0
|
||||
pydantic>=2.6.0
|
||||
pydantic-settings>=2.1.0
|
||||
structlog>=24.1.0
|
||||
markdown>=3.5.0
|
||||
|
||||
@@ -340,6 +340,22 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("reject")
|
||||
p.add_argument("memory_id")
|
||||
|
||||
# batch-extract: fan out /interactions/{id}/extract?persist=true across
|
||||
# recent interactions. Idempotent — the extractor create_memory path
|
||||
# silently skips duplicates, so re-running is safe.
|
||||
p = sub.add_parser("batch-extract")
|
||||
p.add_argument("since", nargs="?", default="")
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
p.add_argument("limit", nargs="?", type=int, default=100)
|
||||
p.add_argument("persist", nargs="?", default="true")
|
||||
|
||||
# triage: interactive candidate review loop. Fetches the queue, shows
|
||||
# each candidate, accepts p/r/s (promote / reject / skip) / q (quit).
|
||||
p = sub.add_parser("triage")
|
||||
p.add_argument("memory_type", nargs="?", default="")
|
||||
p.add_argument("project", nargs="?", default="")
|
||||
p.add_argument("limit", nargs="?", type=int, default=50)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -474,10 +490,141 @@ def main() -> int:
|
||||
{},
|
||||
)
|
||||
)
|
||||
elif cmd == "batch-extract":
|
||||
print_json(run_batch_extract(args.since, args.project, args.limit, args.persist))
|
||||
elif cmd == "triage":
|
||||
return run_triage(args.memory_type, args.project, args.limit)
|
||||
else:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def run_batch_extract(since: str, project: str, limit: int, persist_flag: str) -> dict:
|
||||
"""Fetch recent interactions and run the extractor against each one.
|
||||
|
||||
Returns an aggregated summary. Safe to re-run: the server-side
|
||||
persist path catches ValueError on duplicates and the endpoint
|
||||
reports per-interaction candidate counts either way.
|
||||
"""
|
||||
persist = persist_flag.lower() in {"1", "true", "yes", "y"}
|
||||
query_parts: list[str] = []
|
||||
if project:
|
||||
query_parts.append(f"project={urllib.parse.quote(project)}")
|
||||
if since:
|
||||
query_parts.append(f"since={urllib.parse.quote(since)}")
|
||||
query_parts.append(f"limit={int(limit)}")
|
||||
query = "?" + "&".join(query_parts)
|
||||
|
||||
listing = request("GET", f"/interactions{query}")
|
||||
interactions = listing.get("interactions", []) if isinstance(listing, dict) else []
|
||||
|
||||
processed = 0
|
||||
total_candidates = 0
|
||||
total_persisted = 0
|
||||
errors: list[dict] = []
|
||||
per_interaction: list[dict] = []
|
||||
|
||||
for item in interactions:
|
||||
iid = item.get("id") or ""
|
||||
if not iid:
|
||||
continue
|
||||
try:
|
||||
result = request(
|
||||
"POST",
|
||||
f"/interactions/{urllib.parse.quote(iid, safe='')}/extract",
|
||||
{"persist": persist},
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - network errors land here
|
||||
errors.append({"interaction_id": iid, "error": str(exc)})
|
||||
continue
|
||||
processed += 1
|
||||
count = int(result.get("candidate_count", 0) or 0)
|
||||
persisted_ids = result.get("persisted_ids") or []
|
||||
total_candidates += count
|
||||
total_persisted += len(persisted_ids)
|
||||
if count:
|
||||
per_interaction.append(
|
||||
{
|
||||
"interaction_id": iid,
|
||||
"candidate_count": count,
|
||||
"persisted_count": len(persisted_ids),
|
||||
"project": item.get("project") or "",
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"processed": processed,
|
||||
"total_candidates": total_candidates,
|
||||
"total_persisted": total_persisted,
|
||||
"persist": persist,
|
||||
"errors": errors,
|
||||
"interactions_with_candidates": per_interaction,
|
||||
}
|
||||
|
||||
|
||||
def run_triage(memory_type: str, project: str, limit: int) -> int:
|
||||
"""Interactive review of candidate memories.
|
||||
|
||||
Loads the queue once, walks through entries, prompts for
|
||||
(p)romote / (r)eject / (s)kip / (q)uit. Stateless between runs —
|
||||
re-running picks up whatever is still status=candidate.
|
||||
"""
|
||||
query_parts = ["status=candidate"]
|
||||
if memory_type:
|
||||
query_parts.append(f"memory_type={urllib.parse.quote(memory_type)}")
|
||||
if project:
|
||||
query_parts.append(f"project={urllib.parse.quote(project)}")
|
||||
query_parts.append(f"limit={int(limit)}")
|
||||
listing = request("GET", "/memory?" + "&".join(query_parts))
|
||||
memories = listing.get("memories", []) if isinstance(listing, dict) else []
|
||||
|
||||
if not memories:
|
||||
print_json({"status": "empty_queue", "count": 0})
|
||||
return 0
|
||||
|
||||
promoted = 0
|
||||
rejected = 0
|
||||
skipped = 0
|
||||
stopped_early = False
|
||||
|
||||
print(f"Triage queue: {len(memories)} candidate(s)\n", file=sys.stderr)
|
||||
for idx, mem in enumerate(memories, 1):
|
||||
mid = mem.get("id", "")
|
||||
print(f"[{idx}/{len(memories)}] {mem.get('memory_type','?')} project={mem.get('project','')} conf={mem.get('confidence','?')}", file=sys.stderr)
|
||||
print(f" id: {mid}", file=sys.stderr)
|
||||
print(f" {mem.get('content','')}", file=sys.stderr)
|
||||
try:
|
||||
choice = input(" (p)romote / (r)eject / (s)kip / (q)uit > ").strip().lower()
|
||||
except EOFError:
|
||||
stopped_early = True
|
||||
break
|
||||
if choice in {"q", "quit"}:
|
||||
stopped_early = True
|
||||
break
|
||||
if choice in {"p", "promote"}:
|
||||
request("POST", f"/memory/{urllib.parse.quote(mid, safe='')}/promote", {})
|
||||
promoted += 1
|
||||
print(" -> promoted", file=sys.stderr)
|
||||
elif choice in {"r", "reject"}:
|
||||
request("POST", f"/memory/{urllib.parse.quote(mid, safe='')}/reject", {})
|
||||
rejected += 1
|
||||
print(" -> rejected", file=sys.stderr)
|
||||
else:
|
||||
skipped += 1
|
||||
print(" -> skipped", file=sys.stderr)
|
||||
|
||||
print_json(
|
||||
{
|
||||
"reviewed": promoted + rejected + skipped,
|
||||
"promoted": promoted,
|
||||
"rejected": rejected,
|
||||
"skipped": skipped,
|
||||
"stopped_early": stopped_early,
|
||||
"remaining_in_queue": len(memories) - (promoted + rejected + skipped) - (1 if stopped_early else 0),
|
||||
}
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
263
scripts/auto_triage.py
Normal file
263
scripts/auto_triage.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Auto-triage: LLM second-pass over candidate memories.
|
||||
|
||||
Fetches all status=candidate memories from the AtoCore API, asks
|
||||
a triage model (via claude -p) to classify each as promote / reject /
|
||||
needs_human, and executes the verdict via the promote/reject endpoints.
|
||||
Only needs_human candidates remain in the queue for manual review.
|
||||
|
||||
Trust model:
|
||||
- Auto-promote: model says promote AND confidence >= 0.8 AND no
|
||||
duplicate content in existing active memories
|
||||
- Auto-reject: model says reject
|
||||
- needs_human: everything else stays in queue
|
||||
|
||||
Runs host-side (same as batch extraction) because it needs the
|
||||
claude CLI. Intended to be called after batch-extract.sh in the
|
||||
nightly cron, or manually.
|
||||
|
||||
Usage:
|
||||
|
||||
python3 scripts/auto_triage.py --base-url http://localhost:8100
|
||||
python3 scripts/auto_triage.py --dry-run # preview without executing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://localhost:8100")
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_TRIAGE_MODEL", "sonnet")
|
||||
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_TRIAGE_TIMEOUT_S", "60"))
|
||||
AUTO_PROMOTE_MIN_CONFIDENCE = 0.8
|
||||
|
||||
TRIAGE_SYSTEM_PROMPT = """You are a memory triage reviewer for a personal context engine called AtoCore. You review candidate memories extracted from LLM conversations and decide whether each should be promoted to active status, rejected, or flagged for human review.
|
||||
|
||||
You will receive:
|
||||
- The candidate memory content and type
|
||||
- A list of existing active memories for the same project (to check for duplicates)
|
||||
|
||||
For each candidate, output exactly one JSON object:
|
||||
|
||||
{"verdict": "promote|reject|needs_human|contradicts", "confidence": 0.0-1.0, "reason": "one sentence", "conflicts_with": "id of existing memory if contradicts"}
|
||||
|
||||
Rules:
|
||||
|
||||
1. PROMOTE when the candidate states a durable architectural fact, ratified decision, standing rule, or engineering constraint that is NOT already covered by an existing active memory. Confidence should reflect how certain you are this is worth keeping.
|
||||
|
||||
2. REJECT when the candidate is:
|
||||
- A stale point-in-time snapshot ("live SHA is X", "36 active memories")
|
||||
- An implementation detail too granular to be useful as standalone context
|
||||
- A planned-but-not-implemented feature description
|
||||
- A duplicate or near-duplicate of an existing active memory
|
||||
- A session observation or conversational filler
|
||||
- A process rule that belongs in DEV-LEDGER.md or AGENTS.md, not memory
|
||||
|
||||
3. CONTRADICTS when the candidate *conflicts* with an existing active memory (not a duplicate, but states something that can't both be true). Set `conflicts_with` to the existing memory id. This flags the tension for human review instead of silently rejecting or double-storing. Examples: "Option A selected" vs "Option B selected" for the same decision; "uses material X" vs "uses material Y" for the same component.
|
||||
|
||||
4. NEEDS_HUMAN when you're genuinely unsure — the candidate might be valuable but you can't tell without domain knowledge. This should be rare (< 20% of candidates).
|
||||
|
||||
5. Output ONLY the JSON object. No prose, no markdown, no explanation outside the reason field."""
|
||||
|
||||
_sandbox_cwd = None
|
||||
|
||||
|
||||
def get_sandbox_cwd():
|
||||
global _sandbox_cwd
|
||||
if _sandbox_cwd is None:
|
||||
_sandbox_cwd = tempfile.mkdtemp(prefix="ato-triage-")
|
||||
return _sandbox_cwd
|
||||
|
||||
|
||||
def api_get(base_url, path, timeout=10):
|
||||
req = urllib.request.Request(f"{base_url}{path}")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def api_post(base_url, path, body=None, timeout=10):
|
||||
data = json.dumps(body or {}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def fetch_active_memories_for_project(base_url, project):
|
||||
"""Fetch active memories for dedup checking."""
|
||||
params = "active_only=true&limit=50"
|
||||
if project:
|
||||
params += f"&project={urllib.parse.quote(project)}"
|
||||
result = api_get(base_url, f"/memory?{params}")
|
||||
return result.get("memories", [])
|
||||
|
||||
|
||||
def triage_one(candidate, active_memories, model, timeout_s):
|
||||
"""Ask the triage model to classify one candidate."""
|
||||
if not shutil.which("claude"):
|
||||
return {"verdict": "needs_human", "confidence": 0.0, "reason": "claude CLI not available"}
|
||||
|
||||
active_summary = "\n".join(
|
||||
f"- [{m['memory_type']}] {m['content'][:150]}"
|
||||
for m in active_memories[:20]
|
||||
) or "(no active memories for this project)"
|
||||
|
||||
user_message = (
|
||||
f"CANDIDATE TO TRIAGE:\n"
|
||||
f" type: {candidate['memory_type']}\n"
|
||||
f" project: {candidate.get('project') or '(none)'}\n"
|
||||
f" content: {candidate['content']}\n\n"
|
||||
f"EXISTING ACTIVE MEMORIES FOR THIS PROJECT:\n{active_summary}\n\n"
|
||||
f"Return the JSON verdict now."
|
||||
)
|
||||
|
||||
args = [
|
||||
"claude", "-p",
|
||||
"--model", model,
|
||||
"--append-system-prompt", TRIAGE_SYSTEM_PROMPT,
|
||||
"--disable-slash-commands",
|
||||
user_message,
|
||||
]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args, capture_output=True, text=True,
|
||||
timeout=timeout_s, cwd=get_sandbox_cwd(),
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"verdict": "needs_human", "confidence": 0.0, "reason": "triage model timed out"}
|
||||
except Exception as exc:
|
||||
return {"verdict": "needs_human", "confidence": 0.0, "reason": f"subprocess error: {exc}"}
|
||||
|
||||
if completed.returncode != 0:
|
||||
return {"verdict": "needs_human", "confidence": 0.0, "reason": f"claude exit {completed.returncode}"}
|
||||
|
||||
raw = (completed.stdout or "").strip()
|
||||
return parse_verdict(raw)
|
||||
|
||||
|
||||
def parse_verdict(raw):
|
||||
"""Parse the triage model's JSON verdict."""
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
nl = text.find("\n")
|
||||
if nl >= 0:
|
||||
text = text[nl + 1:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
if not text.lstrip().startswith("{"):
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
text = text[start:end + 1]
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return {"verdict": "needs_human", "confidence": 0.0, "reason": "failed to parse triage output"}
|
||||
|
||||
verdict = str(parsed.get("verdict", "needs_human")).strip().lower()
|
||||
if verdict not in {"promote", "reject", "needs_human", "contradicts"}:
|
||||
verdict = "needs_human"
|
||||
|
||||
confidence = parsed.get("confidence", 0.5)
|
||||
try:
|
||||
confidence = max(0.0, min(1.0, float(confidence)))
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.5
|
||||
|
||||
reason = str(parsed.get("reason", "")).strip()[:200]
|
||||
conflicts_with = str(parsed.get("conflicts_with", "")).strip()
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"confidence": confidence,
|
||||
"reason": reason,
|
||||
"conflicts_with": conflicts_with,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Auto-triage candidate memories")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
parser.add_argument("--dry-run", action="store_true", help="preview without executing")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Fetch candidates
|
||||
result = api_get(args.base_url, "/memory?status=candidate&limit=100")
|
||||
candidates = result.get("memories", [])
|
||||
print(f"candidates: {len(candidates)} model: {args.model} dry_run: {args.dry_run}")
|
||||
|
||||
if not candidates:
|
||||
print("queue empty, nothing to triage")
|
||||
return
|
||||
|
||||
# Cache active memories per project for dedup
|
||||
active_cache = {}
|
||||
promoted = rejected = needs_human = errors = 0
|
||||
|
||||
for i, cand in enumerate(candidates, 1):
|
||||
project = cand.get("project") or ""
|
||||
if project not in active_cache:
|
||||
active_cache[project] = fetch_active_memories_for_project(args.base_url, project)
|
||||
|
||||
verdict_obj = triage_one(cand, active_cache[project], args.model, DEFAULT_TIMEOUT_S)
|
||||
verdict = verdict_obj["verdict"]
|
||||
conf = verdict_obj["confidence"]
|
||||
reason = verdict_obj["reason"]
|
||||
conflicts_with = verdict_obj.get("conflicts_with", "")
|
||||
|
||||
mid = cand["id"]
|
||||
label = f"[{i:2d}/{len(candidates)}] {mid[:8]} [{cand['memory_type']}]"
|
||||
|
||||
if verdict == "promote" and conf >= AUTO_PROMOTE_MIN_CONFIDENCE:
|
||||
if args.dry_run:
|
||||
print(f" WOULD PROMOTE {label} conf={conf:.2f} {reason}")
|
||||
else:
|
||||
try:
|
||||
api_post(args.base_url, f"/memory/{mid}/promote")
|
||||
print(f" PROMOTED {label} conf={conf:.2f} {reason}")
|
||||
active_cache[project].append(cand)
|
||||
except Exception:
|
||||
errors += 1
|
||||
promoted += 1
|
||||
elif verdict == "reject":
|
||||
if args.dry_run:
|
||||
print(f" WOULD REJECT {label} conf={conf:.2f} {reason}")
|
||||
else:
|
||||
try:
|
||||
api_post(args.base_url, f"/memory/{mid}/reject")
|
||||
print(f" REJECTED {label} conf={conf:.2f} {reason}")
|
||||
except Exception:
|
||||
errors += 1
|
||||
rejected += 1
|
||||
elif verdict == "contradicts":
|
||||
# Leave candidate in queue but flag the conflict in content
|
||||
# so the wiki/triage shows it. This is conservative: we
|
||||
# don't silently merge or reject when sources disagree.
|
||||
print(f" CONTRADICTS {label} vs {conflicts_with[:8] if conflicts_with else '?'} {reason}")
|
||||
contradicts_count = locals().get('contradicts_count', 0) + 1
|
||||
needs_human += 1
|
||||
else:
|
||||
print(f" NEEDS_HUMAN {label} conf={conf:.2f} {reason}")
|
||||
needs_human += 1
|
||||
|
||||
print(f"\npromoted={promoted} rejected={rejected} needs_human={needs_human} errors={errors}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
359
scripts/batch_llm_extract_live.py
Normal file
359
scripts/batch_llm_extract_live.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""Host-side LLM batch extraction — pure HTTP client, no atocore imports.
|
||||
|
||||
Fetches interactions from the AtoCore API, runs ``claude -p`` locally
|
||||
for each, and POSTs candidates back. Zero dependency on atocore source
|
||||
or Python packages — only uses stdlib + the ``claude`` CLI on PATH.
|
||||
|
||||
This is necessary because the ``claude`` CLI is on the Dalidou HOST
|
||||
but not inside the Docker container, and the host's Python doesn't
|
||||
have the container's dependencies (pydantic_settings, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://localhost:8100")
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_LLM_EXTRACTOR_MODEL", "sonnet")
|
||||
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_LLM_EXTRACTOR_TIMEOUT_S", "90"))
|
||||
MAX_RESPONSE_CHARS = 8000
|
||||
MAX_PROMPT_CHARS = 2000
|
||||
|
||||
MEMORY_TYPES = {"identity", "preference", "project", "episodic", "knowledge", "adaptation"}
|
||||
|
||||
SYSTEM_PROMPT = """You extract memory candidates from LLM conversation turns for a personal context engine called AtoCore.
|
||||
|
||||
AtoCore is the brain for Atomaste's engineering work. Known projects:
|
||||
p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, atocore,
|
||||
abb-space. Unknown project names — still tag them, the system auto-detects.
|
||||
|
||||
Your job is to emit SIGNALS that matter for future context. Be aggressive:
|
||||
err on the side of capturing useful signal. Triage filters noise downstream.
|
||||
|
||||
WHAT TO EMIT (in order of importance):
|
||||
|
||||
1. PROJECT ACTIVITY — any mention of a project with context worth remembering:
|
||||
- "Schott quote received for ABB-Space" (event + project)
|
||||
- "Cédric asked about p06 firmware timing" (stakeholder event)
|
||||
- "Still waiting on Zygo lead-time from Nabeel" (blocker status)
|
||||
- "p05 vendor decision needs to happen this week" (action item)
|
||||
|
||||
2. DECISIONS AND CHOICES — anything that commits to a direction:
|
||||
- "Going with Zygo Verifire SV for p05" (decision)
|
||||
- "Dropping stitching from primary workflow" (design choice)
|
||||
- "USB SSD mandatory, not SD card" (architectural commitment)
|
||||
|
||||
3. DURABLE ENGINEERING INSIGHT — earned knowledge that generalizes:
|
||||
- "CTE gradient dominates WFE at F/1.2" (materials insight)
|
||||
- "Preston model breaks below 5N because contact assumption fails"
|
||||
- "m=1 coma NOT correctable by force modulation" (controls insight)
|
||||
Test: would a competent engineer NEED experience to know this?
|
||||
If it's textbook/google-findable, skip it.
|
||||
|
||||
4. STAKEHOLDER AND VENDOR EVENTS:
|
||||
- "Email sent to Nabeel 2026-04-13 asking for lead time"
|
||||
- "Meeting with Jason on Table 7 next Tuesday"
|
||||
- "Starspec wants updated CAD by Friday"
|
||||
|
||||
5. PREFERENCES AND ADAPTATIONS that shape how Antoine works:
|
||||
- "Antoine prefers OAuth over API keys"
|
||||
- "Extraction stays off the capture hot path"
|
||||
|
||||
WHAT TO SKIP:
|
||||
|
||||
- Pure conversational filler ("ok thanks", "let me check")
|
||||
- Instructional help content ("run this command", "here's how to...")
|
||||
- Obvious textbook facts anyone can google in 30 seconds
|
||||
- Session meta-chatter ("let me commit this", "deploy running")
|
||||
- Transient system state snapshots ("36 active memories right now")
|
||||
|
||||
CANDIDATE TYPES — choose the best fit:
|
||||
|
||||
- project — a fact, decision, or event specific to one named project
|
||||
- knowledge — durable engineering insight (use domain, not project)
|
||||
- preference — how Antoine works / wants things done
|
||||
- adaptation — a standing rule or adjustment to behavior
|
||||
- episodic — a stakeholder event or milestone worth remembering
|
||||
|
||||
DOMAINS for knowledge candidates (required when type=knowledge and project is empty):
|
||||
physics, materials, optics, mechanics, manufacturing, metrology,
|
||||
controls, software, math, finance, business
|
||||
|
||||
TRUST HIERARCHY:
|
||||
|
||||
- project-specific: set project to the project id, leave domain empty
|
||||
- domain knowledge: set domain, leave project empty
|
||||
- events/activity: use project, type=project or episodic
|
||||
- one conversation can produce MULTIPLE candidates — emit them all
|
||||
|
||||
OUTPUT RULES:
|
||||
|
||||
- Each candidate content under 250 characters, stands alone
|
||||
- Default confidence 0.5. Raise to 0.7 only for ratified/committed claims.
|
||||
- Raw JSON array, no prose, no markdown fences
|
||||
- Empty array [] is fine when the conversation has no durable signal
|
||||
|
||||
Each element:
|
||||
{"type": "project|knowledge|preference|adaptation|episodic", "content": "...", "project": "...", "domain": "", "confidence": 0.5}"""
|
||||
|
||||
_sandbox_cwd = None
|
||||
|
||||
|
||||
def get_sandbox_cwd():
|
||||
global _sandbox_cwd
|
||||
if _sandbox_cwd is None:
|
||||
_sandbox_cwd = tempfile.mkdtemp(prefix="ato-llm-extract-")
|
||||
return _sandbox_cwd
|
||||
|
||||
|
||||
def api_get(base_url, path, timeout=10):
|
||||
req = urllib.request.Request(f"{base_url}{path}")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def api_post(base_url, path, body, timeout=10):
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def get_last_run(base_url):
|
||||
try:
|
||||
state = api_get(base_url, "/project/state/atocore?category=status")
|
||||
for entry in state.get("entries", []):
|
||||
if entry.get("key") == "last_extract_batch_run":
|
||||
return entry["value"]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def set_last_run(base_url, timestamp):
|
||||
try:
|
||||
api_post(base_url, "/project/state", {
|
||||
"project": "atocore", "category": "status",
|
||||
"key": "last_extract_batch_run", "value": timestamp,
|
||||
"source": "batch_llm_extract_live.py",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_known_projects: set[str] = set()
|
||||
|
||||
|
||||
def _load_known_projects(base_url):
|
||||
"""Fetch registered project IDs from the API for R9 validation."""
|
||||
global _known_projects
|
||||
try:
|
||||
data = api_get(base_url, "/projects")
|
||||
_known_projects = {p["id"] for p in data.get("projects", [])}
|
||||
for p in data.get("projects", []):
|
||||
for alias in p.get("aliases", []):
|
||||
_known_projects.add(alias)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def extract_one(prompt, response, project, model, timeout_s):
|
||||
"""Run claude -p on one interaction, return parsed candidates."""
|
||||
if not shutil.which("claude"):
|
||||
return [], "claude_cli_missing"
|
||||
|
||||
prompt_excerpt = prompt[:MAX_PROMPT_CHARS]
|
||||
response_excerpt = response[:MAX_RESPONSE_CHARS]
|
||||
user_message = (
|
||||
f"PROJECT HINT (may be empty): {project}\n\n"
|
||||
f"USER PROMPT:\n{prompt_excerpt}\n\n"
|
||||
f"ASSISTANT RESPONSE:\n{response_excerpt}\n\n"
|
||||
"Return the JSON array now."
|
||||
)
|
||||
|
||||
args = [
|
||||
"claude", "-p",
|
||||
"--model", model,
|
||||
"--append-system-prompt", SYSTEM_PROMPT,
|
||||
"--disable-slash-commands",
|
||||
user_message,
|
||||
]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args, capture_output=True, text=True,
|
||||
timeout=timeout_s, cwd=get_sandbox_cwd(),
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return [], "timeout"
|
||||
except Exception as exc:
|
||||
return [], f"subprocess_error: {exc}"
|
||||
|
||||
if completed.returncode != 0:
|
||||
return [], f"exit_{completed.returncode}"
|
||||
|
||||
raw = (completed.stdout or "").strip()
|
||||
return parse_candidates(raw, project), ""
|
||||
|
||||
|
||||
def parse_candidates(raw, interaction_project):
|
||||
"""Parse model JSON output into candidate dicts."""
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
nl = text.find("\n")
|
||||
if nl >= 0:
|
||||
text = text[nl + 1:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
if not text or text == "[]":
|
||||
return []
|
||||
|
||||
if not text.lstrip().startswith("["):
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start >= 0 and end > start:
|
||||
text = text[start:end + 1]
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
|
||||
results = []
|
||||
for item in parsed:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
mem_type = str(item.get("type") or "").strip().lower()
|
||||
content = str(item.get("content") or "").strip()
|
||||
model_project = str(item.get("project") or "").strip()
|
||||
domain = str(item.get("domain") or "").strip().lower()
|
||||
# R9 trust hierarchy: interaction scope always wins when set.
|
||||
# For unscoped interactions, keep model's project tag even if
|
||||
# unregistered — the system will detect new projects/leads.
|
||||
if interaction_project:
|
||||
project = interaction_project
|
||||
elif model_project:
|
||||
project = model_project
|
||||
else:
|
||||
project = ""
|
||||
# Domain knowledge: embed tag in content for cross-project retrieval
|
||||
if domain and not project:
|
||||
content = f"[{domain}] {content}"
|
||||
conf = item.get("confidence", 0.5)
|
||||
if mem_type not in MEMORY_TYPES or not content:
|
||||
continue
|
||||
try:
|
||||
conf = max(0.0, min(1.0, float(conf)))
|
||||
except (TypeError, ValueError):
|
||||
conf = 0.5
|
||||
results.append({
|
||||
"memory_type": mem_type,
|
||||
"content": content[:1000],
|
||||
"project": project,
|
||||
"confidence": conf,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Host-side LLM batch extraction")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--limit", type=int, default=50)
|
||||
parser.add_argument("--since", default=None)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_known_projects(args.base_url)
|
||||
since = args.since or get_last_run(args.base_url)
|
||||
print(f"since={since or '(first run)'} limit={args.limit} model={args.model} known_projects={len(_known_projects)}")
|
||||
|
||||
params = [f"limit={args.limit}"]
|
||||
if since:
|
||||
params.append(f"since={urllib.parse.quote(since)}")
|
||||
listing = api_get(args.base_url, f"/interactions?{'&'.join(params)}")
|
||||
interaction_summaries = listing.get("interactions", [])
|
||||
print(f"listed {len(interaction_summaries)} interactions")
|
||||
|
||||
processed = 0
|
||||
total_candidates = 0
|
||||
total_persisted = 0
|
||||
errors = 0
|
||||
|
||||
for summary in interaction_summaries:
|
||||
resp_chars = summary.get("response_chars", 0) or 0
|
||||
if resp_chars < 50:
|
||||
continue
|
||||
iid = summary["id"]
|
||||
try:
|
||||
raw = api_get(
|
||||
args.base_url,
|
||||
f"/interactions/{urllib.parse.quote(iid, safe='')}",
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f" ! {iid[:8]}: fetch failed: {exc}", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
response_text = raw.get("response", "") or ""
|
||||
if not response_text.strip() or len(response_text) < 50:
|
||||
continue
|
||||
|
||||
candidates, error = extract_one(
|
||||
prompt=raw.get("prompt", "") or "",
|
||||
response=response_text,
|
||||
project=raw.get("project", "") or "",
|
||||
model=args.model,
|
||||
timeout_s=DEFAULT_TIMEOUT_S,
|
||||
)
|
||||
|
||||
if error:
|
||||
print(f" ! {raw['id'][:8]}: {error}", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
processed += 1
|
||||
total_candidates += len(candidates)
|
||||
|
||||
for c in candidates:
|
||||
try:
|
||||
api_post(args.base_url, "/memory", {
|
||||
"memory_type": c["memory_type"],
|
||||
"content": c["content"],
|
||||
"project": c["project"],
|
||||
"confidence": c["confidence"],
|
||||
"status": "candidate",
|
||||
})
|
||||
total_persisted += 1
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code != 400:
|
||||
errors += 1
|
||||
except Exception:
|
||||
errors += 1
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
set_last_run(args.base_url, now)
|
||||
|
||||
print(f"processed={processed} candidates={total_candidates} persisted={total_persisted} errors={errors}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
188
scripts/bootstrap_entities.py
Normal file
188
scripts/bootstrap_entities.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Bootstrap engineering entities from existing project knowledge.
|
||||
|
||||
One-shot script that seeds the entity/relationship graph from what
|
||||
AtoCore already knows via memories, project state, and vault docs.
|
||||
Safe to re-run — uses name+project dedup.
|
||||
|
||||
Usage:
|
||||
|
||||
python3 scripts/bootstrap_entities.py --base-url http://localhost:8100
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://dalidou:8100")
|
||||
|
||||
|
||||
def post(base_url, path, body):
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def entity(base_url, etype, name, project="", desc="", props=None):
|
||||
result = post(base_url, "/entities", {
|
||||
"entity_type": etype, "name": name, "project": project,
|
||||
"description": desc, "properties": props or {},
|
||||
})
|
||||
eid = result.get("id", "")
|
||||
status = "+" if eid else "skip"
|
||||
print(f" {status} [{etype}] {name}")
|
||||
return eid
|
||||
|
||||
|
||||
def rel(base_url, src, tgt, rtype):
|
||||
if not src or not tgt:
|
||||
return
|
||||
result = post(base_url, "/relationships", {
|
||||
"source_entity_id": src, "target_entity_id": tgt,
|
||||
"relationship_type": rtype,
|
||||
})
|
||||
print(f" -> {rtype}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
args = parser.parse_args()
|
||||
b = args.base_url
|
||||
|
||||
print("=== P04 GigaBIT M1 ===")
|
||||
p04 = entity(b, "project", "GigaBIT M1", "p04-gigabit",
|
||||
"1.2m primary mirror for stratospheric balloon telescope")
|
||||
|
||||
p04_m1 = entity(b, "system", "M1 Mirror Assembly", "p04-gigabit",
|
||||
"Primary mirror blank + support system + reference frame")
|
||||
rel(b, p04, p04_m1, "contains")
|
||||
|
||||
p04_vs = entity(b, "subsystem", "Vertical Support", "p04-gigabit",
|
||||
"18-point whiffletree axial support from below")
|
||||
p04_ls = entity(b, "subsystem", "Lateral Support", "p04-gigabit",
|
||||
"Circumferential constraint system with GF-PTFE pads")
|
||||
p04_rf = entity(b, "subsystem", "Reference Frame", "p04-gigabit",
|
||||
"Structural mounting interface between mirror and OTA")
|
||||
p04_blank = entity(b, "component", "M1 Blank", "p04-gigabit",
|
||||
"1.2m Zerodur aspheric blank from Schott",
|
||||
{"material": "Zerodur", "diameter_m": 1.2, "focal_ratio": "F/1.2"})
|
||||
rel(b, p04_m1, p04_vs, "contains")
|
||||
rel(b, p04_m1, p04_ls, "contains")
|
||||
rel(b, p04_m1, p04_rf, "contains")
|
||||
rel(b, p04_m1, p04_blank, "contains")
|
||||
|
||||
p04_zerodur = entity(b, "material", "Zerodur", "p04-gigabit",
|
||||
"Glass-ceramic with near-zero CTE for mirror blanks")
|
||||
p04_ptfe = entity(b, "material", "GF-PTFE", "p04-gigabit",
|
||||
"Glass-filled PTFE for thermal stability on lateral pads")
|
||||
rel(b, p04_blank, p04_zerodur, "uses_material")
|
||||
rel(b, p04_ls, p04_ptfe, "uses_material")
|
||||
|
||||
p04_optb = entity(b, "decision", "Option B Conical Back", "p04-gigabit",
|
||||
"Selected mirror architecture: conical-back lightweighting")
|
||||
rel(b, p04_optb, p04_blank, "affected_by_decision")
|
||||
|
||||
p04_wfe = entity(b, "requirement", "WFE < 15nm RMS filtered", "p04-gigabit",
|
||||
"Filtered mechanical wavefront error below 15 nm across 20-60 deg elevation")
|
||||
p04_mass = entity(b, "requirement", "Mass < 103.5 kg", "p04-gigabit",
|
||||
"Total mirror assembly mass constraint")
|
||||
rel(b, p04_m1, p04_wfe, "constrained_by")
|
||||
rel(b, p04_m1, p04_mass, "constrained_by")
|
||||
|
||||
print("\n=== P05 Interferometer ===")
|
||||
p05 = entity(b, "project", "Interferometer System", "p05-interferometer",
|
||||
"Metrology system for GigaBIT M1 figuring")
|
||||
|
||||
p05_rig = entity(b, "system", "Test Rig", "p05-interferometer",
|
||||
"Folded-beam interferometric test setup for M1 measurement")
|
||||
rel(b, p05, p05_rig, "contains")
|
||||
|
||||
p05_ifm = entity(b, "component", "Interferometer", "p05-interferometer",
|
||||
"Fixed horizontal Twyman-Green dynamic interferometer")
|
||||
p05_fold = entity(b, "component", "Fold Mirror", "p05-interferometer",
|
||||
"45-degree beam redirect, <= lambda/20 surface quality")
|
||||
p05_cgh = entity(b, "component", "CGH Null Corrector", "p05-interferometer",
|
||||
"6-inch transmission CGH for F/1.2 asphere null test",
|
||||
{"diameter": "6 inch", "substrate": "fused silica", "error_budget_nm": 5.5})
|
||||
p05_tilt = entity(b, "subsystem", "Tilting Platform", "p05-interferometer",
|
||||
"Mirror tilting platform, co-tilts with interferometer")
|
||||
rel(b, p05_rig, p05_ifm, "contains")
|
||||
rel(b, p05_rig, p05_fold, "contains")
|
||||
rel(b, p05_rig, p05_cgh, "contains")
|
||||
rel(b, p05_rig, p05_tilt, "contains")
|
||||
rel(b, p05_ifm, p05_fold, "interfaces_with")
|
||||
rel(b, p05_cgh, p05_tilt, "interfaces_with")
|
||||
|
||||
p05_vendor_dec = entity(b, "decision", "Vendor Path: Twyman-Green preferred", "p05-interferometer",
|
||||
"4D technical lead but cost-challenged; Zygo Verifire SV at 55K is value path")
|
||||
p05_vendor_zygo = entity(b, "vendor", "Zygo / AMETEK", "p05-interferometer",
|
||||
"Certified used Verifire SV, 55K, Nabeel Sufi contact")
|
||||
p05_vendor_4d = entity(b, "vendor", "4D Technology", "p05-interferometer",
|
||||
"PC6110/PC4030, above budget but strongest technical option")
|
||||
p05_vendor_aom = entity(b, "vendor", "AOM (CGH)", "p05-interferometer",
|
||||
"CGH design and fabrication, 28-30K package")
|
||||
rel(b, p05_vendor_dec, p05_ifm, "affected_by_decision")
|
||||
|
||||
print("\n=== P06 Polisher ===")
|
||||
p06 = entity(b, "project", "Polisher System", "p06-polisher",
|
||||
"Machine overhaul + software suite for optical polishing")
|
||||
|
||||
p06_machine = entity(b, "system", "Polisher Machine", "p06-polisher",
|
||||
"Swing-arm polishing machine with force modulation")
|
||||
p06_sw = entity(b, "system", "Software Suite", "p06-polisher",
|
||||
"Three-layer software: polisher-sim, polisher-post, polisher-control")
|
||||
rel(b, p06, p06_machine, "contains")
|
||||
rel(b, p06, p06_sw, "contains")
|
||||
|
||||
p06_sim = entity(b, "subsystem", "polisher-sim", "p06-polisher",
|
||||
"Digital twin: surface assimilation, removal simulation, planning")
|
||||
p06_post = entity(b, "subsystem", "polisher-post", "p06-polisher",
|
||||
"Bridge: validation, translation, packaging for machine")
|
||||
p06_ctrl = entity(b, "subsystem", "polisher-control", "p06-polisher",
|
||||
"Executor: state machine, interlocks, telemetry, run logs")
|
||||
rel(b, p06_sw, p06_sim, "contains")
|
||||
rel(b, p06_sw, p06_post, "contains")
|
||||
rel(b, p06_sw, p06_ctrl, "contains")
|
||||
rel(b, p06_sim, p06_post, "interfaces_with")
|
||||
rel(b, p06_post, p06_ctrl, "interfaces_with")
|
||||
|
||||
p06_fc = entity(b, "subsystem", "Force Control", "p06-polisher",
|
||||
"Frame-grounded counterweight actuator with cable tension modulation",
|
||||
{"actuator_capacity_N": "150-200", "compliance_spring_Nmm": "3-5"})
|
||||
p06_zaxis = entity(b, "component", "Z-Axis", "p06-polisher",
|
||||
"Binary engage/retract mechanism, not continuous position")
|
||||
p06_cam = entity(b, "component", "Cam Mechanism", "p06-polisher",
|
||||
"Mechanically set by operator, read by encoders, not actuated")
|
||||
rel(b, p06_machine, p06_fc, "contains")
|
||||
rel(b, p06_machine, p06_zaxis, "contains")
|
||||
rel(b, p06_machine, p06_cam, "contains")
|
||||
|
||||
p06_fw = entity(b, "decision", "Firmware Interface Contract", "p06-polisher",
|
||||
"controller-job.v1 in, run-log.v1 + telemetry out — invariant")
|
||||
p06_offline = entity(b, "decision", "Offline-First Design", "p06-polisher",
|
||||
"Machine works fully offline; network is for remote access only")
|
||||
p06_usb = entity(b, "decision", "USB SSD Storage", "p06-polisher",
|
||||
"USB SSD mandatory on RPi, not SD card")
|
||||
|
||||
p06_contracts = entity(b, "constraint", "Shared Contracts", "p06-polisher",
|
||||
"Stable IDs, explicit versions, hashable artifacts, planned-vs-executed separation")
|
||||
rel(b, p06_sw, p06_contracts, "constrained_by")
|
||||
|
||||
p06_preston = entity(b, "parameter", "Preston Coefficient kp", "p06-polisher",
|
||||
"Calibrated from before/after surface measurements, multi-run inverse-variance weighting")
|
||||
|
||||
print(f"\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
scripts/eval_data/candidate_queue_2026-04-12.json
Normal file
1
scripts/eval_data/candidate_queue_2026-04-12.json
Normal file
File diff suppressed because one or more lines are too long
29
scripts/eval_data/candidate_queue_2026-04-12.txt
Normal file
29
scripts/eval_data/candidate_queue_2026-04-12.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
1. [project ] proj=atocore AtoCore extraction must stay off the hot capture path; batch endpoint only
|
||||
2. [project ] proj=atocore Auto-promote gate: confidence ≥0.8 AND no duplicate in active memories
|
||||
3. [project ] proj=atocore AtoCore LLM extraction pipeline deployed on Dalidou host, runs via cron at 03:00 UTC via scripts/batch_llm_extract_live.py
|
||||
4. [project ] proj=atocore LLM extractor runs host-side (not in container) because claude CLI not available in container environment
|
||||
5. [project ] proj=atocore Host-side extraction script scripts/batch_llm_extract_live.py uses pure stdlib, no atocore imports for deployment simplicity
|
||||
6. [project ] proj=atocore POST /admin/extract-batch accepts mode: rule|llm, POST /interactions/{id}/extract now mode-aware
|
||||
7. [knowledge ] proj=atocore claude CLI 2.0.60 removed --no-session-persistence flag, extraction sessions now persist in claude history
|
||||
8. [adaptation ] proj=atocore Durable memory extraction candidates must be <200 chars, stand-alone, typed as project|knowledge|preference|adaptation
|
||||
9. [adaptation ] proj=atocore Memory extraction confidence defaults to 0.5, raise to 0.6 only for unambiguous committed claims
|
||||
10. [project ] proj=atocore Live Dalidou is on commit 39d73e9, not e2895b5
|
||||
11. [project ] proj=atocore Live harness is reproducible at 16/18 PASS
|
||||
12. [project ] proj=atocore Live active memories count is 36
|
||||
13. [project ] proj=atocore Wave 2 project-state entries on live: p04=5, p05=6, p06=6
|
||||
14. [project ] proj=atocore R6 is fixed by commit 39d73e9
|
||||
15. [project ] proj=atocore R9: R6 fix only covers empty project fallback; wrong non-empty model project can still override known interaction scope
|
||||
16. [project ] proj=atocore R10: Phase 8 is baseline-complete but not primary-complete; OpenClaw client covers narrow read-oriented slice of API
|
||||
17. [project ] proj=atocore Phase 8 is decent baseline integration milestone but not primary-ready yet
|
||||
18. [project ] proj=atocore 4-step roadmap complete: extractor → harness → Wave 2 → OpenClaw
|
||||
19. [project ] proj=atocore Codex audit loop proven across two full round-trips in one session
|
||||
20. [project ] proj=atocore Session end state: 36 active memories, 17 project-state entries, 16/18 harness, 280 tests, main at 54d84b5
|
||||
21. [project ] proj=atocore AtoCore extraction stays off the hot capture path; LLM extraction runs as scheduled batch, not inline with POST /interactions.
|
||||
22. [project ] proj=atocore AtoCore auto-triage trust model: auto-promote only when confidence ≥0.8 AND no duplicate active memory; else needs_human.
|
||||
23. [project ] proj=atocore Multi-model triage: use different model for triage reviewer than extractor (sonnet for extract)
|
||||
24. [project ] proj=atocore R9 fix: when interaction has known project, prefer it over model's non-matching project unless model's is registered
|
||||
25. [project ] proj=atocore R7 ranking fix: add overlap-density as secondary signal (overlap_count / memory_token_count)
|
||||
26. [project ] proj=atocore Extraction pipeline skips interactions with response_chars < 50 to avoid low-signal content
|
||||
27. [project ] proj=atocore AtoCore triage uses independent model from extractor (extractor: sonnet, triage: different model or different prompt).
|
||||
28. [project ] proj=atocore AtoCore ranking scorer adds overlap-density (overlap_count / memory_tokens) as secondary signal to fix short-memory ranking.
|
||||
29. [project ] proj=atocore AtoCore project trust: when interaction has known project and model returns different project, prefer interaction's project unless
|
||||
51
scripts/eval_data/candidate_queue_snapshot.jsonl
Normal file
51
scripts/eval_data/candidate_queue_snapshot.jsonl
Normal file
@@ -0,0 +1,51 @@
|
||||
{"id": "0dd85386-cace-4f9a-9098-c6732f3c64fa", "type": "project", "project": "atocore", "confidence": 0.5, "content": "AtoCore roadmap: (1) extractor improvement, (2) harness expansion, (3) Wave 2 ingestion, (4) OpenClaw finish; steps 1+2 are current mini-phase"}
|
||||
{"id": "8939b875-152c-4c90-8614-3cfdc64cd1d6", "type": "knowledge", "project": "atocore", "confidence": 0.5, "content": "AtoCore is FastAPI (Python 3.12, SQLite + ChromaDB) on Dalidou home server (dalidou:8100), repo C:\\Users\\antoi\\ATOCore, data /srv/storage/atocore/, ingests Obsidian vault + Google Drive into vector memory system."}
|
||||
{"id": "93e37d2a-b512-4a97-b230-e64ac913d087", "type": "knowledge", "project": "atocore", "confidence": 0.5, "content": "Deploy AtoCore: git push origin main, then ssh papa@dalidou and run /srv/storage/atocore/app/deploy/dalidou/deploy.sh"}
|
||||
{"id": "4b82fe01-4393-464a-b935-9ad5d112d3d8", "type": "adaptation", "project": "atocore", "confidence": 0.5, "content": "Do not add memory extraction to interaction capture hot path; keep extraction as separate batch/manual step. Reason: latency and queue noise before review rhythm is comfortable."}
|
||||
{"id": "c873ec00-063e-488c-ad32-1233290a3feb", "type": "project", "project": "atocore", "confidence": 0.5, "content": "As of 2026-04-11, approved roadmap in order: observe reinforcement, batch extraction, candidate triage, off-Dalidou backup, retrieval quality review."}
|
||||
{"id": "665cdd27-0057-4e73-82f5-5d4f47189b5d", "type": "project", "project": "atocore", "confidence": 0.5, "content": "AtoCore adopts DEV-LEDGER.md as shared operating memory with stable headers; updated at session boundaries"}
|
||||
{"id": "5f89c51d-7e8b-4fb9-830d-a35bb649f9f7", "type": "adaptation", "project": "atocore", "confidence": 0.5, "content": "Codex branches for AtoCore fork from main (never orphan); use naming pattern codex/<topic>"}
|
||||
{"id": "25ac367c-8bbe-4ba4-8d8e-d533db33f2d9", "type": "adaptation", "project": "atocore", "confidence": 0.5, "content": "In AtoCore, Claude builds and Codex audits; never work in parallel on same files"}
|
||||
{"id": "89446ebe-fd42-4177-80db-3657bc41d048", "type": "adaptation", "project": "atocore", "confidence": 0.5, "content": "In AtoCore, P1-severity findings in DEV-LEDGER.md block further main commits until acknowledged"}
|
||||
{"id": "1f077e98-f945-4480-96ab-110b0671ebc6", "type": "adaptation", "project": "atocore", "confidence": 0.5, "content": "Every AtoCore session appends to DEV-LEDGER.md Session Log and updates Orientation before ending"}
|
||||
{"id": "89f60018-c23b-4b2f-80ca-e6f7d02c5cd3", "type": "preference", "project": "atocore", "confidence": 0.5, "content": "User prefers receiving standalone testing prompts they can paste into Claude Code on target deployments rather than having the assistant run tests directly."}
|
||||
{"id": "2f69a6ed-6de2-4565-87df-1ea3e8c42963", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "USB SSD on RPi is mandatory for polishing telemetry storage; must be independent of network for data integrity during runs."}
|
||||
{"id": "6bcaebde-9e45-4de5-a220-65d9c4cd451e", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Use Tailscale mesh for RPi remote access to provide SSH, file transfer, and NAT traversal without port forwarding."}
|
||||
{"id": "82f17880-92da-485e-a24a-0599ab1836e7", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Auto-sync telemetry data via rsync over Tailscale after runs complete; fire-and-forget pattern with automatic retry on network interruption."}
|
||||
{"id": "2dd36f74-db47-4c72-a185-fec025d07d4f", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Real-time telemetry monitoring should target 10 Hz downsampling; full 100 Hz streaming over network is not necessary."}
|
||||
{"id": "7519d82b-8065-41f0-812e-9c1a3573d7b9", "type": "knowledge", "project": "p06-polisher", "confidence": 0.5, "content": "Polishing telemetry data rate is approximately 29 MB per hour (100 Hz × 20 channels × 4 bytes = 8 KB/s)."}
|
||||
{"id": "78678162-5754-478b-b1fc-e25f22e0ee03", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Machine spec (shareable) + Atomaste spec (internal) separate concerns. Machine spec hides program generation as 'separate scope' to protect IP/business strategy."}
|
||||
{"id": "6657b4ae-d4ec-4fec-a66f-2975cdb10d13", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Firmware interface contract is invariant: controller-job.v1 input, run-log.v1 + telemetry output. No firmware changes needed regardless of program generation implementation."}
|
||||
{"id": "6d6f4fe9-73e5-449f-a802-6dc0a974f87b", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Atomaste sim spec documents forward/return paths, calibration model (Preston k), translation loss, and service/IP strategy—details hidden from shareable machine spec."}
|
||||
{"id": "932f38df-58f3-49c2-9968-8d422dc54b42", "type": "project", "project": "", "confidence": 0.5, "content": "USB SSD mandatory for storage (not SD card); directory structure /data/runs/{id}/, /data/manual/{id}/; status.json for machine state"}
|
||||
{"id": "2b3178e8-fe38-4338-b2b0-75a01da18cea", "type": "project", "project": "", "confidence": 0.5, "content": "RPi joins Tailscale mesh for remote access over SSH VPN; no public IP or port forwarding; fully offline operation"}
|
||||
{"id": "254c394d-3f80-4b34-a891-9f1cbfec74d7", "type": "project", "project": "", "confidence": 0.5, "content": "Data synchronization via rsync over Tailscale, failure-tolerant and non-blocking; USB stick as manual fallback"}
|
||||
{"id": "ee626650-1ee0-439c-85c9-6d32a876f239", "type": "project", "project": "", "confidence": 0.5, "content": "Machine design principle: works fully offline and independently; network connection is for remote access only"}
|
||||
{"id": "34add99d-8d2e-4586-b002-fc7b7d22bcb3", "type": "project", "project": "", "confidence": 0.5, "content": "No cloud, no real-time streaming, no remote control features in design scope"}
|
||||
{"id": "993e0afe-9910-4984-b608-f5e9de7c0453", "type": "project", "project": "atocore", "confidence": 0.5, "content": "P1: Reflection loop integration incomplete—extraction remains manual (POST /interactions/{id}/extract), not auto-triggered with reinforcement. Live capture won't auto-populate candidate review queue."}
|
||||
{"id": "bdf488d7-9200-441e-afbf-5335020ea78b", "type": "project", "project": "atocore", "confidence": 0.5, "content": "P1: Project memories excluded from context injection; build_context() requests [\"identity\", \"preference\"] only. Reinforcement signal doesn't reach assembled context packs."}
|
||||
{"id": "188197af-a61d-4616-9e39-712aeaaadf61", "type": "project", "project": "atocore", "confidence": 0.5, "content": "Current batch-extract rules produce only 1 candidate from 42 real captures. Extractor needs conversational-cue detection or LLM-assisted path to improve yield."}
|
||||
{"id": "acffcaa4-5966-4ec1-a0b2-3b8dcebe75bd", "type": "project", "project": "atocore", "confidence": 0.5, "content": "Next priority: extractor rule expansion (cheapest validation of reflection loop), then Wave 2 trusted operational ingestion (master-plan priority). Defer retrieval eval harness focus."}
|
||||
{"id": "1b44a886-a5af-4426-bf10-a92baf3a6502", "type": "knowledge", "project": "atocore", "confidence": 0.5, "content": "Alias canonicalization fix (resolve_project_name() boundary) is consistently applied across project state, memories, interactions, and context lookup. Code review approved directionally."}
|
||||
{"id": "e8f4e704-367b-4759-b20c-da0ccf06cf7d", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Machine capabilities now define z_type: engage_retract and cam_type: mechanical_with_encoder instead of actuator-driven setpoints."}
|
||||
{"id": "ab2b607c-52b1-405f-a874-c6078393c21c", "type": "knowledge", "project": "", "confidence": 0.5, "content": "Codex is an audit agent; communicate with it via markdown prompts with numbered steps; it updates findings via commits to codex/* branches or direct messages."}
|
||||
{"id": "5a5fd29d-291f-4e22-88fe-825cf55f745a", "type": "preference", "project": "", "confidence": 0.5, "content": "Audit-first workflow recommended: have codex audit DEV-LEDGER.md and recent commits before execution; validates round-trip, catches errors early."}
|
||||
{"id": "4c238106-017e-4283-99a1-639497b6ddde", "type": "knowledge", "project": "", "confidence": 0.5, "content": "DEV-LEDGER.md at repo root is the shared coordination document with Orientation, Active Plan, and Open Review Findings sections."}
|
||||
{"id": "83aed988-4257-4220-b612-6c725d6cd95a", "type": "project", "project": "atocore", "confidence": 0.5, "content": "Roadmap: Extractor improvement → Harness expansion → Wave 2 trusted operational ingestion → Finish OpenClaw integration (in that order)"}
|
||||
{"id": "95d87d1a-5daa-414d-95ff-a344a62e0b6b", "type": "project", "project": "atocore", "confidence": 0.5, "content": "Phase 1 (Extractor): eval-driven loop—label captures, improve rules/add LLM mode, measure yield & FP, stop when queue reviewable (not coverage metrics)"}
|
||||
{"id": "7aafb588-51b0-4536-a414-ebaaea924b98", "type": "project", "project": "atocore", "confidence": 0.5, "content": "Phases 1 & 2 (Extractor + Harness) are a mini-phase; without harness, extractor improvements are blind edits"}
|
||||
{"id": "aa50c51a-27d7-4db9-b7a3-7ca75dba2118", "type": "knowledge", "project": "", "confidence": 0.5, "content": "Dalidou stores Claude Code interactions via a Stop hook that fires after each turn and POSTs to http://dalidou:8100/interactions with client=claude-code parameter"}
|
||||
{"id": "5951108b-3a5e-49d0-9308-dfab449664d3", "type": "adaptation", "project": "", "confidence": 0.5, "content": "Interaction capture system is passive and automatic; no manual action required, interactions accumulate automatically during normal Claude Code usage"}
|
||||
{"id": "9d2cbbe9-cf2e-4aab-9cb8-c4951da70826", "type": "project", "project": "", "confidence": 0.5, "content": "Session Log/Ledger system tracks work state across sessions so future sessions immediately know what is true and what is next; phases marked by git SHAs."}
|
||||
{"id": "db88eecf-e31a-4fee-b07d-0b51db7e315e", "type": "project", "project": "atocore", "confidence": 0.5, "content": "atocore uses multi-model coordination: Claude and codex share DEV-LEDGER.md (current state / active plan / P1+P2 findings / recent decisions / commit log) read at session start, appended at session end"}
|
||||
{"id": "8748f071-ff28-47a6-8504-65ca30a8336a", "type": "project", "project": "atocore", "confidence": 0.5, "content": "atocore starts with manual-event-loop (/audit or /status prompts) using DEV-LEDGER.md before upgrading to automated git hooks/CI review"}
|
||||
{"id": "f9210883-67a8-4dae-9f27-6b5ae7bd8a6b", "type": "project", "project": "atocore", "confidence": 0.5, "content": "atocore development involves coordinating between Claude and codex models with shared plan/review strategy and counter-validation to improve system quality"}
|
||||
{"id": "85f008b9-2d6d-49ad-81a1-e254dac2a2ac", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Z-axis is a binary engage/retract mechanism (z_engaged bool), not continuous position control; confirmation timeout z_engage_timeout_s required."}
|
||||
{"id": "0cc417ed-ac38-4231-9786-a9582ac6a60f", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Cam amplitude and offset are mechanically set by operator and read via encoders; no actuators control them, controller receives encoder telemetry only."}
|
||||
{"id": "2e001aaf-0c5c-4547-9b96-ebc4172b258d", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Cam parameters in controller are expected_cam_amplitude_deg and expected_cam_offset_deg (read-only reference for verification), not command setpoints."}
|
||||
{"id": "47778126-b0cf-41d9-9e21-f2418f53e792", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Manual mode UI displays cam encoder readings (cam_amplitude_deg, cam_offset_deg) as read-only for operator verification of mechanical setting."}
|
||||
{"id": "410e4a70-ae12-4de2-8f31-071ffee3cad4", "type": "project", "project": "p06-polisher", "confidence": 0.5, "content": "Manual session log records cam_setting measured at session start; run-log segment actual block includes cam_amplitude_deg_mean and cam_offset_deg_mean."}
|
||||
{"id": "e94f94f0-3538-40dd-aef2-0189eacc7eb7", "type": "knowledge", "project": "atocore", "confidence": 0.5, "content": "AtoCore deployments to dalidou use the script /srv/storage/atocore/app/deploy/dalidou/deploy.sh instead of manual docker commands"}
|
||||
{"id": "23fa6fdf-cfb9-4850-ad04-3ea56551c30a", "type": "project", "project": "", "confidence": 0.5, "content": "Retrieval/extraction evaluation follows 8-day mini-phase plan with hard gates to prevent scope drift. Preflight checks must validate git SHAs, baselines, and fixture stability before coding."}
|
||||
{"id": "3e1fad28-031b-4670-a9d0-0af2e8ba1361", "type": "project", "project": "", "confidence": 0.5, "content": "Day 1: Create labeled extractor eval set from 30 captures (10 zero-candidate, 10 single-candidate, 10 ambiguous) with metadata; create scoring tool to measure precision/recall."}
|
||||
{"id": "d49378a4-d03c-4730-be87-f0fcb2d199db", "type": "project", "project": "", "confidence": 0.5, "content": "Day 2: Measure current extractor against labeled set, recording yield, true/false positives, and false negatives by pattern."}
|
||||
145
scripts/eval_data/extractor_labels_2026-04-11.json
Normal file
145
scripts/eval_data/extractor_labels_2026-04-11.json
Normal file
@@ -0,0 +1,145 @@
|
||||
{
|
||||
"version": "0.1",
|
||||
"frozen_at": "2026-04-11",
|
||||
"snapshot_file": "scripts/eval_data/interactions_snapshot_2026-04-11.json",
|
||||
"labeled_count": 20,
|
||||
"plan_deviation": "Codex's plan called for 30 labeled interactions (10 zero / 10 plausible / 10 ambiguous). Actual corpus is heavily skewed toward instructional/status content; after reading 20 drawn by length-stratified random sample, the honest positive rate is ~25% (5/20). Labeling more would mostly add zeros; the Day 2 measurement is not bottlenecked on sample size.",
|
||||
"positive_count": 5,
|
||||
"labels": [
|
||||
{
|
||||
"id": "ab239158-d6ac-4c51-b6e4-dd4ccea384a2",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Instructional deploy guidance. No durable claim."
|
||||
},
|
||||
{
|
||||
"id": "da153f2a-b20a-4dee-8c72-431ebb71f08c",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "'Deploy still in progress.' Pure status."
|
||||
},
|
||||
{
|
||||
"id": "7d8371ee-c6d3-4dfe-a7b0-2d091f075c15",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Git command walkthrough. No durable claim."
|
||||
},
|
||||
{
|
||||
"id": "14bf3f90-e318-466e-81ac-d35522741ba5",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Ledger status update. Transient fact, not a durable memory candidate."
|
||||
},
|
||||
{
|
||||
"id": "8f855235-c38d-4c27-9f2b-8530ebe1a2d8",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Short-term recommendation ('merge to main and deploy'), not a standing decision."
|
||||
},
|
||||
{
|
||||
"id": "04a96eb5-cd00-4e9f-9252-b2cc919000a4",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Dev server config table. Operational detail, not a memory."
|
||||
},
|
||||
{
|
||||
"id": "79d606ed-8981-454a-83af-c25226b1b65c",
|
||||
"expected_count": 1,
|
||||
"expected_type": "adaptation",
|
||||
"expected_project": "",
|
||||
"expected_snippet": "shared DEV-LEDGER as operating memory",
|
||||
"miss_class": "recommendation_prose",
|
||||
"notes": "A recommendation that later became a ratified decision. Rule extractor would need a 'simplest version that could work today' / 'I'd start with' cue class."
|
||||
},
|
||||
{
|
||||
"id": "a6b0d279-c564-4bce-a703-e476f4a148ad",
|
||||
"expected_count": 2,
|
||||
"expected_type": "project",
|
||||
"expected_project": "p06-polisher",
|
||||
"expected_snippet": "z_engaged bool; cam amplitude set mechanically and read by encoders",
|
||||
"miss_class": "architectural_change_summary",
|
||||
"notes": "Two durable architectural facts about the polisher machine (Z-axis is engage/retract, cam is read-only). Extractor would need to recognize 'A is now B' / 'X removed, Y added' patterns."
|
||||
},
|
||||
{
|
||||
"id": "4e00e398-2e89-4653-8ee5-3f65c7f4d2d3",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Clarification question to user."
|
||||
},
|
||||
{
|
||||
"id": "a6a7816a-7590-4616-84f4-49d9054c2a91",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Instructional response offering two next moves."
|
||||
},
|
||||
{
|
||||
"id": "03527502-316a-4a3e-989c-00719392c7d1",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Troubleshooting a paste failure. Ephemeral."
|
||||
},
|
||||
{
|
||||
"id": "1fff59fc-545f-42df-9dd1-a0e6dec1b7ee",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Agreement + follow-up question. No durable claim."
|
||||
},
|
||||
{
|
||||
"id": "eb65dc18-0030-4720-ace7-f55af9df719d",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Explanation of how the capture hook works. Instructional."
|
||||
},
|
||||
{
|
||||
"id": "52c8c0f3-32fb-4b48-9065-73c778a08417",
|
||||
"expected_count": 1,
|
||||
"expected_type": "project",
|
||||
"expected_project": "p06-polisher",
|
||||
"expected_snippet": "USB SSD mandatory on RPi; Tailscale for remote access",
|
||||
"miss_class": "spec_update_announcement",
|
||||
"notes": "Concrete architectural commitments just added to the polisher spec. Phrased as '§17.1 Local Storage - USB SSD mandatory, not SD card.' The '§' section markers could be a new cue."
|
||||
},
|
||||
{
|
||||
"id": "32d40414-15af-47ee-944b-2cceae9574b8",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Session recap. Historical summary, not a durable memory."
|
||||
},
|
||||
{
|
||||
"id": "b6d2cdfc-37fb-459a-96bd-caefb9beaab4",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Deployment prompt for Dalidou. Operational, not a memory."
|
||||
},
|
||||
{
|
||||
"id": "ee03d823-931b-4d4e-9258-88b4ed5eeb07",
|
||||
"expected_count": 2,
|
||||
"expected_type": "knowledge",
|
||||
"expected_project": "p06-polisher",
|
||||
"expected_snippet": "USB SSD is non-negotiable for local storage; Tailscale mesh for SSH/file transfer",
|
||||
"miss_class": "layered_recommendation",
|
||||
"notes": "Layered infra recommendation with 'non-negotiable' / 'strongly recommended' strength markers. The 'non-negotiable' token could be a new cue class."
|
||||
},
|
||||
{
|
||||
"id": "dd234d9f-0d1c-47e8-b01c-eebcb568c7e7",
|
||||
"expected_count": 1,
|
||||
"expected_type": "project",
|
||||
"expected_project": "p06-polisher",
|
||||
"expected_snippet": "interface contract is identical regardless of who generates the programs; machine is a standalone box",
|
||||
"miss_class": "alignment_assertion",
|
||||
"notes": "Architectural invariant assertion. '**Alignment verified**' / 'nothing changes for X' style. Likely too subtle for rule matching without LLM assistance."
|
||||
},
|
||||
{
|
||||
"id": "1f95891a-cf37-400e-9d68-4fad8e04dcbb",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Huge session handoff prompt. Informational only."
|
||||
},
|
||||
{
|
||||
"id": "5580950f-d010-4544-be4b-b3071271a698",
|
||||
"expected_count": 0,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Ledger schema sketch. Structural design proposal, later ratified — but the same idea was already captured as a ratified decision in the recent decisions section, so not worth re-extracting from this conversational form."
|
||||
}
|
||||
]
|
||||
}
|
||||
518
scripts/eval_data/extractor_llm_baseline_2026-04-11.json
Normal file
518
scripts/eval_data/extractor_llm_baseline_2026-04-11.json
Normal file
@@ -0,0 +1,518 @@
|
||||
{
|
||||
"summary": {
|
||||
"total": 20,
|
||||
"exact_match": 6,
|
||||
"positive_expected": 5,
|
||||
"total_expected_candidates": 7,
|
||||
"total_actual_candidates": 51,
|
||||
"yield_rate": 2.55,
|
||||
"recall": 1.0,
|
||||
"precision": 0.357,
|
||||
"false_positive_interactions": 9,
|
||||
"false_negative_interactions": 0,
|
||||
"miss_classes": {},
|
||||
"mode": "llm"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"id": "ab239158-d6ac-4c51-b6e4-dd4ccea384a2",
|
||||
"expected_count": 0,
|
||||
"actual_count": 1,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Instructional deploy guidance. No durable claim.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "AtoCore deployments to dalidou use the script /srv/storage/atocore/app/deploy/dalidou/deploy.sh instead of manual docker commands",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "da153f2a-b20a-4dee-8c72-431ebb71f08c",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "'Deploy still in progress.' Pure status.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "7d8371ee-c6d3-4dfe-a7b0-2d091f075c15",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Git command walkthrough. No durable claim.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "14bf3f90-e318-466e-81ac-d35522741ba5",
|
||||
"expected_count": 0,
|
||||
"actual_count": 4,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Ledger status update. Transient fact, not a durable memory candidate.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Retrieval/extraction evaluation follows 8-day mini-phase plan with hard gates to prevent scope drift. Preflight checks must validate git SHAs, baselines, and fixture stability before coding.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Day 1: Create labeled extractor eval set from 30 captures (10 zero-candidate, 10 single-candidate, 10 ambiguous) with metadata; create scoring tool to measure precision/recall.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Day 2: Measure current extractor against labeled set, recording yield, true/false positives, and false negatives by pattern.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Session Log/Ledger system tracks work state across sessions so future sessions immediately know what is true and what is next; phases marked by git SHAs.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "8f855235-c38d-4c27-9f2b-8530ebe1a2d8",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Short-term recommendation ('merge to main and deploy'), not a standing decision.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "04a96eb5-cd00-4e9f-9252-b2cc919000a4",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Dev server config table. Operational detail, not a memory.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "79d606ed-8981-454a-83af-c25226b1b65c",
|
||||
"expected_count": 1,
|
||||
"actual_count": 3,
|
||||
"ok": false,
|
||||
"miss_class": "recommendation_prose",
|
||||
"notes": "A recommendation that later became a ratified decision. Rule extractor would need a 'simplest version that could work today' / 'I'd start with' cue class.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "atocore uses multi-model coordination: Claude and codex share DEV-LEDGER.md (current state / active plan / P1+P2 findings / recent decisions / commit log) read at session start, appended at session end",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "atocore starts with manual-event-loop (/audit or /status prompts) using DEV-LEDGER.md before upgrading to automated git hooks/CI review",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "atocore development involves coordinating between Claude and codex models with shared plan/review strategy and counter-validation to improve system quality",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a6b0d279-c564-4bce-a703-e476f4a148ad",
|
||||
"expected_count": 2,
|
||||
"actual_count": 6,
|
||||
"ok": false,
|
||||
"miss_class": "architectural_change_summary",
|
||||
"notes": "Two durable architectural facts about the polisher machine (Z-axis is engage/retract, cam is read-only). Extractor would need to recognize 'A is now B' / 'X removed, Y added' patterns.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Z-axis is a binary engage/retract mechanism (z_engaged bool), not continuous position control; confirmation timeout z_engage_timeout_s required.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Cam amplitude and offset are mechanically set by operator and read via encoders; no actuators control them, controller receives encoder telemetry only.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Cam parameters in controller are expected_cam_amplitude_deg and expected_cam_offset_deg (read-only reference for verification), not command setpoints.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Manual mode UI displays cam encoder readings (cam_amplitude_deg, cam_offset_deg) as read-only for operator verification of mechanical setting.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Manual session log records cam_setting measured at session start; run-log segment actual block includes cam_amplitude_deg_mean and cam_offset_deg_mean.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Machine capabilities now define z_type: engage_retract and cam_type: mechanical_with_encoder instead of actuator-driven setpoints.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "4e00e398-2e89-4653-8ee5-3f65c7f4d2d3",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Clarification question to user.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "a6a7816a-7590-4616-84f4-49d9054c2a91",
|
||||
"expected_count": 0,
|
||||
"actual_count": 3,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Instructional response offering two next moves.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "Codex is an audit agent; communicate with it via markdown prompts with numbered steps; it updates findings via commits to codex/* branches or direct messages.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "preference",
|
||||
"content": "Audit-first workflow recommended: have codex audit DEV-LEDGER.md and recent commits before execution; validates round-trip, catches errors early.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "DEV-LEDGER.md at repo root is the shared coordination document with Orientation, Active Plan, and Open Review Findings sections.",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "03527502-316a-4a3e-989c-00719392c7d1",
|
||||
"expected_count": 0,
|
||||
"actual_count": 0,
|
||||
"ok": true,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Troubleshooting a paste failure. Ephemeral.",
|
||||
"actual_candidates": []
|
||||
},
|
||||
{
|
||||
"id": "1fff59fc-545f-42df-9dd1-a0e6dec1b7ee",
|
||||
"expected_count": 0,
|
||||
"actual_count": 3,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Agreement + follow-up question. No durable claim.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Roadmap: Extractor improvement → Harness expansion → Wave 2 trusted operational ingestion → Finish OpenClaw integration (in that order)",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Phase 1 (Extractor): eval-driven loop—label captures, improve rules/add LLM mode, measure yield & FP, stop when queue reviewable (not coverage metrics)",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Phases 1 & 2 (Extractor + Harness) are a mini-phase; without harness, extractor improvements are blind edits",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "eb65dc18-0030-4720-ace7-f55af9df719d",
|
||||
"expected_count": 0,
|
||||
"actual_count": 2,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Explanation of how the capture hook works. Instructional.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "Dalidou stores Claude Code interactions via a Stop hook that fires after each turn and POSTs to http://dalidou:8100/interactions with client=claude-code parameter",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "Interaction capture system is passive and automatic; no manual action required, interactions accumulate automatically during normal Claude Code usage",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "52c8c0f3-32fb-4b48-9065-73c778a08417",
|
||||
"expected_count": 1,
|
||||
"actual_count": 5,
|
||||
"ok": false,
|
||||
"miss_class": "spec_update_announcement",
|
||||
"notes": "Concrete architectural commitments just added to the polisher spec. Phrased as '§17.1 Local Storage - USB SSD mandatory, not SD card.' The '§' section markers could be a new cue.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "USB SSD mandatory for storage (not SD card); directory structure /data/runs/{id}/, /data/manual/{id}/; status.json for machine state",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "RPi joins Tailscale mesh for remote access over SSH VPN; no public IP or port forwarding; fully offline operation",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Data synchronization via rsync over Tailscale, failure-tolerant and non-blocking; USB stick as manual fallback",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Machine design principle: works fully offline and independently; network connection is for remote access only",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "No cloud, no real-time streaming, no remote control features in design scope",
|
||||
"project": "",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "32d40414-15af-47ee-944b-2cceae9574b8",
|
||||
"expected_count": 0,
|
||||
"actual_count": 5,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Session recap. Historical summary, not a durable memory.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "P1: Reflection loop integration incomplete—extraction remains manual (POST /interactions/{id}/extract), not auto-triggered with reinforcement. Live capture won't auto-populate candidate review queue.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "P1: Project memories excluded from context injection; build_context() requests [\"identity\", \"preference\"] only. Reinforcement signal doesn't reach assembled context packs.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Current batch-extract rules produce only 1 candidate from 42 real captures. Extractor needs conversational-cue detection or LLM-assisted path to improve yield.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Next priority: extractor rule expansion (cheapest validation of reflection loop), then Wave 2 trusted operational ingestion (master-plan priority). Defer retrieval eval harness focus.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "Alias canonicalization fix (resolve_project_name() boundary) is consistently applied across project state, memories, interactions, and context lookup. Code review approved directionally.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b6d2cdfc-37fb-459a-96bd-caefb9beaab4",
|
||||
"expected_count": 0,
|
||||
"actual_count": 1,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Deployment prompt for Dalidou. Operational, not a memory.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "preference",
|
||||
"content": "User prefers receiving standalone testing prompts they can paste into Claude Code on target deployments rather than having the assistant run tests directly.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ee03d823-931b-4d4e-9258-88b4ed5eeb07",
|
||||
"expected_count": 2,
|
||||
"actual_count": 5,
|
||||
"ok": false,
|
||||
"miss_class": "layered_recommendation",
|
||||
"notes": "Layered infra recommendation with 'non-negotiable' / 'strongly recommended' strength markers. The 'non-negotiable' token could be a new cue class.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "USB SSD on RPi is mandatory for polishing telemetry storage; must be independent of network for data integrity during runs.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Use Tailscale mesh for RPi remote access to provide SSH, file transfer, and NAT traversal without port forwarding.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Auto-sync telemetry data via rsync over Tailscale after runs complete; fire-and-forget pattern with automatic retry on network interruption.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Real-time telemetry monitoring should target 10 Hz downsampling; full 100 Hz streaming over network is not necessary.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "Polishing telemetry data rate is approximately 29 MB per hour (100 Hz × 20 channels × 4 bytes = 8 KB/s).",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dd234d9f-0d1c-47e8-b01c-eebcb568c7e7",
|
||||
"expected_count": 1,
|
||||
"actual_count": 3,
|
||||
"ok": false,
|
||||
"miss_class": "alignment_assertion",
|
||||
"notes": "Architectural invariant assertion. '**Alignment verified**' / 'nothing changes for X' style. Likely too subtle for rule matching without LLM assistance.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Machine spec (shareable) + Atomaste spec (internal) separate concerns. Machine spec hides program generation as 'separate scope' to protect IP/business strategy.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Firmware interface contract is invariant: controller-job.v1 input, run-log.v1 + telemetry output. No firmware changes needed regardless of program generation implementation.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "Atomaste sim spec documents forward/return paths, calibration model (Preston k), translation loss, and service/IP strategy—details hidden from shareable machine spec.",
|
||||
"project": "p06-polisher",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "1f95891a-cf37-400e-9d68-4fad8e04dcbb",
|
||||
"expected_count": 0,
|
||||
"actual_count": 4,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Huge session handoff prompt. Informational only.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "AtoCore is FastAPI (Python 3.12, SQLite + ChromaDB) on Dalidou home server (dalidou:8100), repo C:\\Users\\antoi\\ATOCore, data /srv/storage/atocore/, ingests Obsidian vault + Google Drive into vector memory system.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"content": "Deploy AtoCore: git push origin main, then ssh papa@dalidou and run /srv/storage/atocore/app/deploy/dalidou/deploy.sh",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "Do not add memory extraction to interaction capture hot path; keep extraction as separate batch/manual step. Reason: latency and queue noise before review rhythm is comfortable.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "As of 2026-04-11, approved roadmap in order: observe reinforcement, batch extraction, candidate triage, off-Dalidou backup, retrieval quality review.",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "5580950f-d010-4544-be4b-b3071271a698",
|
||||
"expected_count": 0,
|
||||
"actual_count": 6,
|
||||
"ok": false,
|
||||
"miss_class": "n/a",
|
||||
"notes": "Ledger schema sketch. Structural design proposal, later ratified — but the same idea was already captured as a ratified decision in the recent decisions section, so not worth re-extracting from this conversational form.",
|
||||
"actual_candidates": [
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "AtoCore adopts DEV-LEDGER.md as shared operating memory with stable headers; updated at session boundaries",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "Codex branches for AtoCore fork from main (never orphan); use naming pattern codex/<topic>",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "In AtoCore, Claude builds and Codex audits; never work in parallel on same files",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "In AtoCore, P1-severity findings in DEV-LEDGER.md block further main commits until acknowledged",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "adaptation",
|
||||
"content": "Every AtoCore session appends to DEV-LEDGER.md Session Log and updates Orientation before ending",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
},
|
||||
{
|
||||
"memory_type": "project",
|
||||
"content": "AtoCore roadmap: (1) extractor improvement, (2) harness expansion, (3) Wave 2 ingestion, (4) OpenClaw finish; steps 1+2 are current mini-phase",
|
||||
"project": "atocore",
|
||||
"rule": "llm_extraction"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
scripts/eval_data/interactions_snapshot_2026-04-11.json
Normal file
1
scripts/eval_data/interactions_snapshot_2026-04-11.json
Normal file
File diff suppressed because one or more lines are too long
1
scripts/eval_data/triage_verdict_2026-04-12.json
Normal file
1
scripts/eval_data/triage_verdict_2026-04-12.json
Normal file
@@ -0,0 +1 @@
|
||||
{"promote": ["4b82fe01-4393-464a-b935-9ad5d112d3d8", "665cdd27-0057-4e73-82f5-5d4f47189b5d", "5f89c51d-7e8b-4fb9-830d-a35bb649f9f7", "25ac367c-8bbe-4ba4-8d8e-d533db33f2d9", "2f69a6ed-6de2-4565-87df-1ea3e8c42963", "6bcaebde-9e45-4de5-a220-65d9c4cd451e", "2dd36f74-db47-4c72-a185-fec025d07d4f", "7519d82b-8065-41f0-812e-9c1a3573d7b9", "78678162-5754-478b-b1fc-e25f22e0ee03", "6657b4ae-d4ec-4fec-a66f-2975cdb10d13", "ee626650-1ee0-439c-85c9-6d32a876f239", "1b44a886-a5af-4426-bf10-a92baf3a6502", "aa50c51a-27d7-4db9-b7a3-7ca75dba2118", "5951108b-3a5e-49d0-9308-dfab449664d3", "85f008b9-2d6d-49ad-81a1-e254dac2a2ac", "0cc417ed-ac38-4231-9786-a9582ac6a60f"], "reject": ["0dd85386-cace-4f9a-9098-c6732f3c64fa", "8939b875-152c-4c90-8614-3cfdc64cd1d6", "93e37d2a-b512-4a97-b230-e64ac913d087", "c873ec00-063e-488c-ad32-1233290a3feb", "89446ebe-fd42-4177-80db-3657bc41d048", "1f077e98-f945-4480-96ab-110b0671ebc6", "89f60018-c23b-4b2f-80ca-e6f7d02c5cd3", "82f17880-92da-485e-a24a-0599ab1836e7", "6d6f4fe9-73e5-449f-a802-6dc0a974f87b", "932f38df-58f3-49c2-9968-8d422dc54b42", "2b3178e8-fe38-4338-b2b0-75a01da18cea", "254c394d-3f80-4b34-a891-9f1cbfec74d7", "34add99d-8d2e-4586-b002-fc7b7d22bcb3", "993e0afe-9910-4984-b608-f5e9de7c0453", "bdf488d7-9200-441e-afbf-5335020ea78b", "188197af-a61d-4616-9e39-712aeaaadf61", "acffcaa4-5966-4ec1-a0b2-3b8dcebe75bd", "e8f4e704-367b-4759-b20c-da0ccf06cf7d", "ab2b607c-52b1-405f-a874-c6078393c21c", "5a5fd29d-291f-4e22-88fe-825cf55f745a", "4c238106-017e-4283-99a1-639497b6ddde", "83aed988-4257-4220-b612-6c725d6cd95a", "95d87d1a-5daa-414d-95ff-a344a62e0b6b", "7aafb588-51b0-4536-a414-ebaaea924b98", "9d2cbbe9-cf2e-4aab-9cb8-c4951da70826", "db88eecf-e31a-4fee-b07d-0b51db7e315e", "8748f071-ff28-47a6-8504-65ca30a8336a", "f9210883-67a8-4dae-9f27-6b5ae7bd8a6b", "2e001aaf-0c5c-4547-9b96-ebc4172b258d", "47778126-b0cf-41d9-9e21-f2418f53e792", "410e4a70-ae12-4de2-8f31-071ffee3cad4", "e94f94f0-3538-40dd-aef2-0189eacc7eb7", "23fa6fdf-cfb9-4850-ad04-3ea56551c30a", "3e1fad28-031b-4670-a9d0-0af2e8ba1361", "d49378a4-d03c-4730-be87-f0fcb2d199db"]}
|
||||
274
scripts/extractor_eval.py
Normal file
274
scripts/extractor_eval.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Extractor eval runner — scores the rule-based extractor against a
|
||||
labeled interaction corpus.
|
||||
|
||||
Pulls full interaction content from a frozen snapshot, runs each through
|
||||
``extract_candidates_from_interaction``, and compares the output to the
|
||||
expected counts from a labels file. Produces a per-label scorecard plus
|
||||
aggregate precision / recall / yield numbers.
|
||||
|
||||
This harness deliberately stays file-based: snapshot + labels + this
|
||||
runner. No Dalidou HTTP dependency once the snapshot is frozen, so the
|
||||
eval is reproducible run-to-run even as live captures drift.
|
||||
|
||||
Usage:
|
||||
|
||||
python scripts/extractor_eval.py # human report
|
||||
python scripts/extractor_eval.py --json # machine-readable
|
||||
python scripts/extractor_eval.py \\
|
||||
--snapshot scripts/eval_data/interactions_snapshot_2026-04-11.json \\
|
||||
--labels scripts/eval_data/extractor_labels_2026-04-11.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
# Force UTF-8 on stdout so real LLM output (arrows, em-dashes, CJK)
|
||||
# doesn't crash the human report on Windows cp1252 consoles.
|
||||
if hasattr(sys.stdout, "buffer"):
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
||||
|
||||
# Make src/ importable without requiring an install.
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(_REPO_ROOT / "src"))
|
||||
|
||||
from atocore.interactions.service import Interaction # noqa: E402
|
||||
from atocore.memory.extractor import extract_candidates_from_interaction # noqa: E402
|
||||
from atocore.memory.extractor_llm import extract_candidates_llm # noqa: E402
|
||||
|
||||
DEFAULT_SNAPSHOT = _REPO_ROOT / "scripts" / "eval_data" / "interactions_snapshot_2026-04-11.json"
|
||||
DEFAULT_LABELS = _REPO_ROOT / "scripts" / "eval_data" / "extractor_labels_2026-04-11.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabelResult:
|
||||
id: str
|
||||
expected_count: int
|
||||
actual_count: int
|
||||
ok: bool
|
||||
miss_class: str
|
||||
notes: str
|
||||
actual_candidates: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_snapshot(path: Path) -> dict[str, dict]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {item["id"]: item for item in data.get("interactions", [])}
|
||||
|
||||
|
||||
def load_labels(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def interaction_from_snapshot(snap: dict) -> Interaction:
|
||||
return Interaction(
|
||||
id=snap["id"],
|
||||
prompt=snap.get("prompt", "") or "",
|
||||
response=snap.get("response", "") or "",
|
||||
response_summary="",
|
||||
project=snap.get("project", "") or "",
|
||||
client=snap.get("client", "") or "",
|
||||
session_id=snap.get("session_id", "") or "",
|
||||
created_at=snap.get("created_at", "") or "",
|
||||
)
|
||||
|
||||
|
||||
def score(snapshot: dict[str, dict], labels_doc: dict, mode: str = "rule") -> list[LabelResult]:
|
||||
results: list[LabelResult] = []
|
||||
for label in labels_doc["labels"]:
|
||||
iid = label["id"]
|
||||
snap = snapshot.get(iid)
|
||||
if snap is None:
|
||||
results.append(
|
||||
LabelResult(
|
||||
id=iid,
|
||||
expected_count=int(label.get("expected_count", 0)),
|
||||
actual_count=-1,
|
||||
ok=False,
|
||||
miss_class="not_in_snapshot",
|
||||
notes=label.get("notes", ""),
|
||||
)
|
||||
)
|
||||
continue
|
||||
interaction = interaction_from_snapshot(snap)
|
||||
if mode == "llm":
|
||||
candidates = extract_candidates_llm(interaction)
|
||||
else:
|
||||
candidates = extract_candidates_from_interaction(interaction)
|
||||
actual_count = len(candidates)
|
||||
expected_count = int(label.get("expected_count", 0))
|
||||
results.append(
|
||||
LabelResult(
|
||||
id=iid,
|
||||
expected_count=expected_count,
|
||||
actual_count=actual_count,
|
||||
ok=(actual_count == expected_count),
|
||||
miss_class=label.get("miss_class", "n/a"),
|
||||
notes=label.get("notes", ""),
|
||||
actual_candidates=[
|
||||
{
|
||||
"memory_type": c.memory_type,
|
||||
"content": c.content,
|
||||
"project": c.project,
|
||||
"rule": c.rule,
|
||||
}
|
||||
for c in candidates
|
||||
],
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def aggregate(results: list[LabelResult]) -> dict:
|
||||
total = len(results)
|
||||
exact_match = sum(1 for r in results if r.ok)
|
||||
true_positive = sum(1 for r in results if r.expected_count > 0 and r.actual_count > 0)
|
||||
false_positive_interactions = sum(
|
||||
1 for r in results if r.expected_count == 0 and r.actual_count > 0
|
||||
)
|
||||
false_negative_interactions = sum(
|
||||
1 for r in results if r.expected_count > 0 and r.actual_count == 0
|
||||
)
|
||||
positive_expected = sum(1 for r in results if r.expected_count > 0)
|
||||
total_expected_candidates = sum(r.expected_count for r in results)
|
||||
total_actual_candidates = sum(max(r.actual_count, 0) for r in results)
|
||||
yield_rate = total_actual_candidates / total if total else 0.0
|
||||
# Recall over interaction count that had at least one expected candidate:
|
||||
recall = true_positive / positive_expected if positive_expected else 0.0
|
||||
# Precision over interaction count that produced any candidate:
|
||||
precision_denom = true_positive + false_positive_interactions
|
||||
precision = true_positive / precision_denom if precision_denom else 0.0
|
||||
# Miss class breakdown
|
||||
miss_classes: dict[str, int] = {}
|
||||
for r in results:
|
||||
if r.expected_count > 0 and r.actual_count == 0:
|
||||
key = r.miss_class or "unlabeled"
|
||||
miss_classes[key] = miss_classes.get(key, 0) + 1
|
||||
return {
|
||||
"total": total,
|
||||
"exact_match": exact_match,
|
||||
"positive_expected": positive_expected,
|
||||
"total_expected_candidates": total_expected_candidates,
|
||||
"total_actual_candidates": total_actual_candidates,
|
||||
"yield_rate": round(yield_rate, 3),
|
||||
"recall": round(recall, 3),
|
||||
"precision": round(precision, 3),
|
||||
"false_positive_interactions": false_positive_interactions,
|
||||
"false_negative_interactions": false_negative_interactions,
|
||||
"miss_classes": miss_classes,
|
||||
}
|
||||
|
||||
|
||||
def print_human(results: list[LabelResult], summary: dict) -> None:
|
||||
print("=== Extractor eval ===")
|
||||
print(
|
||||
f"labeled={summary['total']} "
|
||||
f"exact_match={summary['exact_match']} "
|
||||
f"positive_expected={summary['positive_expected']}"
|
||||
)
|
||||
print(
|
||||
f"yield={summary['yield_rate']} "
|
||||
f"recall={summary['recall']} "
|
||||
f"precision={summary['precision']}"
|
||||
)
|
||||
print(
|
||||
f"false_positives={summary['false_positive_interactions']} "
|
||||
f"false_negatives={summary['false_negative_interactions']}"
|
||||
)
|
||||
print()
|
||||
print("miss class breakdown (FN):")
|
||||
if summary["miss_classes"]:
|
||||
for k, v in sorted(summary["miss_classes"].items(), key=lambda kv: -kv[1]):
|
||||
print(f" {v:3d} {k}")
|
||||
else:
|
||||
print(" (none)")
|
||||
print()
|
||||
print("per-interaction:")
|
||||
for r in results:
|
||||
marker = "OK " if r.ok else "MISS"
|
||||
iid_short = r.id[:8]
|
||||
print(f" {marker} {iid_short} expected={r.expected_count} actual={r.actual_count} class={r.miss_class}")
|
||||
if r.actual_candidates:
|
||||
for c in r.actual_candidates:
|
||||
preview = (c["content"] or "")[:80]
|
||||
print(f" [{c['memory_type']}] {preview}")
|
||||
|
||||
|
||||
def print_json(results: list[LabelResult], summary: dict) -> None:
|
||||
payload = {
|
||||
"summary": summary,
|
||||
"results": [
|
||||
{
|
||||
"id": r.id,
|
||||
"expected_count": r.expected_count,
|
||||
"actual_count": r.actual_count,
|
||||
"ok": r.ok,
|
||||
"miss_class": r.miss_class,
|
||||
"notes": r.notes,
|
||||
"actual_candidates": r.actual_candidates,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
json.dump(payload, sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AtoCore extractor eval")
|
||||
parser.add_argument("--snapshot", type=Path, default=DEFAULT_SNAPSHOT)
|
||||
parser.add_argument("--labels", type=Path, default=DEFAULT_LABELS)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="write JSON result to this file (bypasses log/stdout interleaving)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["rule", "llm"],
|
||||
default="rule",
|
||||
help="which extractor to score (default: rule)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
snapshot = load_snapshot(args.snapshot)
|
||||
labels = load_labels(args.labels)
|
||||
results = score(snapshot, labels, mode=args.mode)
|
||||
summary = aggregate(results)
|
||||
summary["mode"] = args.mode
|
||||
|
||||
if args.output is not None:
|
||||
payload = {
|
||||
"summary": summary,
|
||||
"results": [
|
||||
{
|
||||
"id": r.id,
|
||||
"expected_count": r.expected_count,
|
||||
"actual_count": r.actual_count,
|
||||
"ok": r.ok,
|
||||
"miss_class": r.miss_class,
|
||||
"notes": r.notes,
|
||||
"actual_candidates": r.actual_candidates,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
args.output.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"wrote {args.output} ({summary['mode']}: recall={summary['recall']} precision={summary['precision']})")
|
||||
elif args.json:
|
||||
print_json(results, summary)
|
||||
else:
|
||||
print_human(results, summary)
|
||||
|
||||
return 0 if summary["false_negative_interactions"] == 0 and summary["false_positive_interactions"] == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
254
scripts/import_openclaw_state.py
Normal file
254
scripts/import_openclaw_state.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""OpenClaw state importer — one-way pull from clawdbot into AtoCore.
|
||||
|
||||
Reads OpenClaw's file continuity layer (SOUL.md, USER.md, MODEL-ROUTING.md,
|
||||
MEMORY.md, memory/YYYY-MM-DD.md) from the T420 via SSH and imports them
|
||||
into AtoCore as candidate memories. Hash-based delta detection — only
|
||||
re-imports files that changed since the last run.
|
||||
|
||||
Classification per codex's integration proposal:
|
||||
- SOUL.md -> identity candidates
|
||||
- USER.md -> identity + preference candidates
|
||||
- MODEL-ROUTING.md -> adaptation candidates (routing rules)
|
||||
- MEMORY.md -> long-term memory candidates (type varies)
|
||||
- memory/YYYY-MM-DD.md -> episodic memory candidates (daily logs)
|
||||
- heartbeat-state.json -> skipped (ops metadata only)
|
||||
|
||||
All candidates land as status=candidate. Auto-triage filters noise.
|
||||
This importer is conservative: it doesn't promote directly, it just
|
||||
feeds signal. The triage pipeline decides what graduates to active.
|
||||
|
||||
Usage:
|
||||
python3 scripts/import_openclaw_state.py \
|
||||
--base-url http://localhost:8100 \
|
||||
--openclaw-host papa@192.168.86.39 \
|
||||
--openclaw-path /home/papa/openclaw-workspace
|
||||
|
||||
Runs nightly via cron (added as Step 2c in cron-backup.sh).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://localhost:8100")
|
||||
DEFAULT_OPENCLAW_HOST = os.environ.get("ATOCORE_OPENCLAW_HOST", "papa@192.168.86.39")
|
||||
DEFAULT_OPENCLAW_PATH = os.environ.get("ATOCORE_OPENCLAW_PATH", "/home/papa/openclaw-workspace")
|
||||
|
||||
# Files to pull and how to classify them
|
||||
DURABLE_FILES = [
|
||||
("SOUL.md", "identity"),
|
||||
("USER.md", "identity"),
|
||||
("MODEL-ROUTING.md", "adaptation"),
|
||||
("MEMORY.md", "memory"), # type parsed from entries
|
||||
]
|
||||
DAILY_MEMORY_GLOB = "memory/*.md"
|
||||
HASH_STATE_KEY = "openclaw_import_hashes"
|
||||
|
||||
|
||||
def api_get(base_url, path):
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base_url}{path}", timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def api_post(base_url, path, body):
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 400:
|
||||
return {"skipped": True}
|
||||
raise
|
||||
|
||||
|
||||
def ssh_cat(host, remote_path):
|
||||
"""Cat a remote file via SSH. Returns content or None if missing."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes",
|
||||
host, f"cat {remote_path}"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def ssh_ls(host, remote_glob):
|
||||
"""List files matching a glob on the remote host."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes",
|
||||
host, f"ls -1 {remote_glob} 2>/dev/null"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
encoding="utf-8", errors="replace",
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def content_hash(text):
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def load_hash_state(base_url):
|
||||
"""Load the hash state from project_state so we know what's changed."""
|
||||
state = api_get(base_url, "/project/state/atocore?category=status")
|
||||
if not state:
|
||||
return {}
|
||||
for entry in state.get("entries", []):
|
||||
if entry.get("key") == HASH_STATE_KEY:
|
||||
try:
|
||||
return json.loads(entry["value"])
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_hash_state(base_url, hashes):
|
||||
api_post(base_url, "/project/state", {
|
||||
"project": "atocore",
|
||||
"category": "status",
|
||||
"key": HASH_STATE_KEY,
|
||||
"value": json.dumps(hashes),
|
||||
"source": "import_openclaw_state.py",
|
||||
})
|
||||
|
||||
|
||||
def import_file_as_memory(base_url, filename, content, memory_type, source_tag):
|
||||
"""Import a file's content as a single candidate memory for triage."""
|
||||
# Trim to reasonable size — auto-triage can handle long content but
|
||||
# we don't want single mega-memories dominating the queue
|
||||
trimmed = content[:2000]
|
||||
if len(content) > 2000:
|
||||
trimmed += f"\n\n[...truncated from {len(content)} chars]"
|
||||
|
||||
body = {
|
||||
"memory_type": memory_type,
|
||||
"content": f"From OpenClaw/{filename}: {trimmed}",
|
||||
"project": "", # global/identity, not project-scoped
|
||||
"confidence": 0.5,
|
||||
"status": "candidate",
|
||||
}
|
||||
return api_post(base_url, "/memory", body)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--openclaw-host", default=DEFAULT_OPENCLAW_HOST)
|
||||
parser.add_argument("--openclaw-path", default=DEFAULT_OPENCLAW_PATH)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"openclaw_host={args.openclaw_host} openclaw_path={args.openclaw_path}")
|
||||
print(f"dry_run={args.dry_run}")
|
||||
|
||||
# Check SSH connectivity first
|
||||
test = ssh_cat(args.openclaw_host, f"{args.openclaw_path}/SOUL.md")
|
||||
if test is None:
|
||||
print("ERROR: cannot reach OpenClaw workspace via SSH or SOUL.md not found")
|
||||
print("Check: ssh key installed? path correct? workspace exists?")
|
||||
return 1
|
||||
|
||||
hashes = load_hash_state(args.base_url)
|
||||
imported = skipped = errors = 0
|
||||
|
||||
# 1. Durable files
|
||||
for filename, mem_type in DURABLE_FILES:
|
||||
remote = f"{args.openclaw_path}/{filename}"
|
||||
content = ssh_cat(args.openclaw_host, remote)
|
||||
if content is None or not content.strip():
|
||||
print(f" - {filename}: not found or empty")
|
||||
continue
|
||||
|
||||
h = content_hash(content)
|
||||
if hashes.get(filename) == h:
|
||||
print(f" = {filename}: unchanged (hash {h})")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
print(f" + {filename}: changed (hash {h}, {len(content)}ch)")
|
||||
if not args.dry_run:
|
||||
try:
|
||||
result = import_file_as_memory(
|
||||
args.base_url, filename, content, mem_type,
|
||||
source_tag="openclaw-durable",
|
||||
)
|
||||
if result.get("skipped"):
|
||||
print(f" (duplicate content, skipped)")
|
||||
else:
|
||||
print(f" -> candidate {result.get('id', '?')[:8]}")
|
||||
imported += 1
|
||||
hashes[filename] = h
|
||||
except Exception as e:
|
||||
print(f" ! error: {e}")
|
||||
errors += 1
|
||||
|
||||
# 2. Daily memory logs (memory/YYYY-MM-DD.md)
|
||||
daily_glob = f"{args.openclaw_path}/{DAILY_MEMORY_GLOB}"
|
||||
daily_files = ssh_ls(args.openclaw_host, daily_glob)
|
||||
print(f"\ndaily memory files: {len(daily_files)}")
|
||||
|
||||
# Only process the most recent 7 daily files to avoid flooding
|
||||
for remote_path in sorted(daily_files)[-7:]:
|
||||
filename = Path(remote_path).name
|
||||
content = ssh_cat(args.openclaw_host, remote_path)
|
||||
if content is None or not content.strip():
|
||||
continue
|
||||
|
||||
h = content_hash(content)
|
||||
key = f"daily/{filename}"
|
||||
if hashes.get(key) == h:
|
||||
print(f" = {filename}: unchanged")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
print(f" + {filename}: changed ({len(content)}ch)")
|
||||
if not args.dry_run:
|
||||
try:
|
||||
result = import_file_as_memory(
|
||||
args.base_url, filename, content, "episodic",
|
||||
source_tag="openclaw-daily",
|
||||
)
|
||||
if not result.get("skipped"):
|
||||
print(f" -> candidate {result.get('id', '?')[:8]}")
|
||||
imported += 1
|
||||
hashes[key] = h
|
||||
except Exception as e:
|
||||
print(f" ! error: {e}")
|
||||
errors += 1
|
||||
|
||||
# Save hash state
|
||||
if not args.dry_run and imported > 0:
|
||||
save_hash_state(args.base_url, hashes)
|
||||
|
||||
print(f"\nimported={imported} skipped={skipped} errors={errors}")
|
||||
print("Candidates queued — auto-triage will filter them on next run.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main() or 0)
|
||||
170
scripts/lint_knowledge_base.py
Normal file
170
scripts/lint_knowledge_base.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Weekly lint pass — health check for the AtoCore knowledge base.
|
||||
|
||||
Inspired by Karpathy's LLM Wiki pattern (the 'lint' operation).
|
||||
Checks for orphans, stale claims, contradictions, and gaps.
|
||||
Outputs a report that can be posted to the wiki as needs_review.
|
||||
|
||||
Usage:
|
||||
python3 scripts/lint_knowledge_base.py --base-url http://dalidou:8100
|
||||
|
||||
Run weekly via cron, or on-demand when the knowledge base feels stale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://localhost:8100")
|
||||
ORPHAN_AGE_DAYS = 14
|
||||
|
||||
|
||||
def api_get(base_url: str, path: str):
|
||||
with urllib.request.urlopen(f"{base_url}{path}", timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def parse_ts(ts: str) -> datetime | None:
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
args = parser.parse_args()
|
||||
b = args.base_url
|
||||
now = datetime.now(timezone.utc)
|
||||
orphan_threshold = now - timedelta(days=ORPHAN_AGE_DAYS)
|
||||
|
||||
print(f"=== AtoCore Lint — {now.strftime('%Y-%m-%d %H:%M UTC')} ===\n")
|
||||
|
||||
findings = {
|
||||
"orphan_memories": [],
|
||||
"stale_candidates": [],
|
||||
"unused_entities": [],
|
||||
"empty_state_projects": [],
|
||||
"unregistered_projects": [],
|
||||
}
|
||||
|
||||
# 1. Orphan memories: active but never reinforced after N days
|
||||
memories = api_get(b, "/memory?active_only=true&limit=500").get("memories", [])
|
||||
for m in memories:
|
||||
updated = parse_ts(m.get("updated_at", ""))
|
||||
if m.get("reference_count", 0) == 0 and updated and updated < orphan_threshold:
|
||||
findings["orphan_memories"].append({
|
||||
"id": m["id"],
|
||||
"type": m["memory_type"],
|
||||
"project": m.get("project") or "(none)",
|
||||
"age_days": (now - updated).days,
|
||||
"content": m["content"][:120],
|
||||
})
|
||||
|
||||
# 2. Stale candidates: been in queue > 7 days without triage
|
||||
candidates = api_get(b, "/memory?status=candidate&limit=500").get("memories", [])
|
||||
stale_threshold = now - timedelta(days=7)
|
||||
for c in candidates:
|
||||
updated = parse_ts(c.get("updated_at", ""))
|
||||
if updated and updated < stale_threshold:
|
||||
findings["stale_candidates"].append({
|
||||
"id": c["id"],
|
||||
"age_days": (now - updated).days,
|
||||
"content": c["content"][:120],
|
||||
})
|
||||
|
||||
# 3. Unused entities: no relationships in either direction
|
||||
entities = api_get(b, "/entities?limit=500").get("entities", [])
|
||||
for e in entities:
|
||||
try:
|
||||
detail = api_get(b, f"/entities/{e['id']}")
|
||||
if not detail.get("relationships"):
|
||||
findings["unused_entities"].append({
|
||||
"id": e["id"],
|
||||
"type": e["entity_type"],
|
||||
"name": e["name"],
|
||||
"project": e.get("project") or "(none)",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Registered projects with no state entries
|
||||
try:
|
||||
projects = api_get(b, "/projects").get("projects", [])
|
||||
for p in projects:
|
||||
state = api_get(b, f"/project/state/{p['id']}").get("entries", [])
|
||||
if not state:
|
||||
findings["empty_state_projects"].append(p["id"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. Memories tagged to unregistered projects (auto-detection candidates)
|
||||
registered_ids = {p["id"] for p in projects} | {
|
||||
a for p in projects for a in p.get("aliases", [])
|
||||
}
|
||||
all_mems = api_get(b, "/memory?limit=500").get("memories", [])
|
||||
for m in all_mems:
|
||||
proj = m.get("project", "")
|
||||
if proj and proj not in registered_ids and proj != "(none)":
|
||||
if proj not in findings["unregistered_projects"]:
|
||||
findings["unregistered_projects"].append(proj)
|
||||
|
||||
# Print report
|
||||
print(f"## Orphan memories (active, no reinforcement, >{ORPHAN_AGE_DAYS} days old)")
|
||||
if findings["orphan_memories"]:
|
||||
print(f" Found: {len(findings['orphan_memories'])}")
|
||||
for o in findings["orphan_memories"][:10]:
|
||||
print(f" - [{o['type']}] {o['project']} ({o['age_days']}d): {o['content']}")
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
print(f"\n## Stale candidates (>7 days in queue)")
|
||||
if findings["stale_candidates"]:
|
||||
print(f" Found: {len(findings['stale_candidates'])}")
|
||||
for s in findings["stale_candidates"][:10]:
|
||||
print(f" - ({s['age_days']}d): {s['content']}")
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
print(f"\n## Unused entities (no relationships)")
|
||||
if findings["unused_entities"]:
|
||||
print(f" Found: {len(findings['unused_entities'])}")
|
||||
for u in findings["unused_entities"][:10]:
|
||||
print(f" - [{u['type']}] {u['project']}: {u['name']}")
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
print(f"\n## Empty-state projects")
|
||||
if findings["empty_state_projects"]:
|
||||
print(f" Found: {len(findings['empty_state_projects'])}")
|
||||
for p in findings["empty_state_projects"]:
|
||||
print(f" - {p}")
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
print(f"\n## Unregistered projects detected in memories")
|
||||
if findings["unregistered_projects"]:
|
||||
print(f" Found: {len(findings['unregistered_projects'])}")
|
||||
print(" These were auto-detected by extraction — consider registering them:")
|
||||
for p in findings["unregistered_projects"]:
|
||||
print(f" - {p}")
|
||||
else:
|
||||
print(" (none)")
|
||||
|
||||
total_findings = sum(
|
||||
len(v) if isinstance(v, list) else 0 for v in findings.values()
|
||||
)
|
||||
print(f"\n=== Total findings: {total_findings} ===")
|
||||
|
||||
# Return exit code based on findings count (for CI)
|
||||
return 0 if total_findings == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
89
scripts/persist_llm_candidates.py
Normal file
89
scripts/persist_llm_candidates.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Persist LLM-extracted candidates from a baseline JSON to Dalidou.
|
||||
|
||||
One-shot script: reads a saved extractor eval output file, filters to
|
||||
candidates the LLM actually produced, and POSTs each to the Dalidou
|
||||
memory API with ``status=candidate``. Deduplicates against already-
|
||||
existing candidate content so the script is safe to re-run.
|
||||
|
||||
Usage:
|
||||
|
||||
python scripts/persist_llm_candidates.py \\
|
||||
scripts/eval_data/extractor_llm_baseline_2026-04-11.json
|
||||
|
||||
Then triage via:
|
||||
|
||||
python scripts/atocore_client.py triage
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://dalidou:8100")
|
||||
TIMEOUT = int(os.environ.get("ATOCORE_TIMEOUT_SECONDS", "10"))
|
||||
|
||||
|
||||
def post_json(path: str, body: dict) -> dict:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url=f"{BASE_URL}{path}",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print(f"usage: {sys.argv[0]} <baseline_json>", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
data = json.loads(open(sys.argv[1], encoding="utf-8").read())
|
||||
results = data.get("results", [])
|
||||
|
||||
persisted = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for r in results:
|
||||
for c in r.get("actual_candidates", []):
|
||||
content = (c.get("content") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
mem_type = c.get("memory_type", "knowledge")
|
||||
project = c.get("project", "")
|
||||
confidence = c.get("confidence", 0.5)
|
||||
|
||||
try:
|
||||
resp = post_json("/memory", {
|
||||
"memory_type": mem_type,
|
||||
"content": content,
|
||||
"project": project,
|
||||
"confidence": float(confidence),
|
||||
"status": "candidate",
|
||||
})
|
||||
persisted += 1
|
||||
print(f" + {resp.get('id','?')[:8]} [{mem_type}] {content[:80]}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 400:
|
||||
skipped += 1
|
||||
else:
|
||||
errors += 1
|
||||
print(f" ! error {exc.code}: {content[:60]}", file=sys.stderr)
|
||||
except Exception as exc:
|
||||
errors += 1
|
||||
print(f" ! {exc}: {content[:60]}", file=sys.stderr)
|
||||
|
||||
print(f"\npersisted={persisted} skipped={skipped} errors={errors}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
194
scripts/retrieval_eval.py
Normal file
194
scripts/retrieval_eval.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Retrieval quality eval harness.
|
||||
|
||||
Runs a fixed set of project-hinted questions against
|
||||
``POST /context/build`` on a live AtoCore instance and scores the
|
||||
resulting ``formatted_context`` against per-question expectations.
|
||||
The goal is a diffable scorecard that tells you, run-to-run,
|
||||
whether a retrieval / builder / ingestion change moved the needle.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- Fixtures live in ``scripts/retrieval_eval_fixtures.json`` so new
|
||||
questions can be added without touching Python. Each fixture
|
||||
names the project, the prompt, and a checklist of substrings that
|
||||
MUST appear in ``formatted_context`` (``expect_present``) and
|
||||
substrings that MUST NOT appear (``expect_absent``). The absent
|
||||
list catches cross-project bleed and stale content.
|
||||
- The checklist is deliberately substring-based (not regex, not
|
||||
embedding-similarity) so a failure is always a trivially
|
||||
reproducible "this string is not in that string". Richer scoring
|
||||
can come later once we know the harness is useful.
|
||||
- The harness is external to the app runtime and talks to AtoCore
|
||||
over HTTP, so it works against dev, staging, or prod. It follows
|
||||
the same environment-variable contract as ``atocore_client.py``
|
||||
(``ATOCORE_BASE_URL``, ``ATOCORE_TIMEOUT_SECONDS``).
|
||||
- Exit code 0 on all-pass, 1 on any fixture failure. Intended for
|
||||
manual runs today; a future cron / CI hook can consume the
|
||||
JSON output via ``--json``.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
python scripts/retrieval_eval.py # human-readable report
|
||||
python scripts/retrieval_eval.py --json # machine-readable
|
||||
python scripts/retrieval_eval.py --fixtures path/to/custom.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://dalidou:8100")
|
||||
DEFAULT_TIMEOUT = int(os.environ.get("ATOCORE_TIMEOUT_SECONDS", "30"))
|
||||
DEFAULT_BUDGET = 3000
|
||||
DEFAULT_FIXTURES = Path(__file__).parent / "retrieval_eval_fixtures.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
name: str
|
||||
project: str
|
||||
prompt: str
|
||||
budget: int = DEFAULT_BUDGET
|
||||
expect_present: list[str] = field(default_factory=list)
|
||||
expect_absent: list[str] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FixtureResult:
|
||||
fixture: Fixture
|
||||
ok: bool
|
||||
missing_present: list[str]
|
||||
unexpected_absent: list[str]
|
||||
total_chars: int
|
||||
error: str = ""
|
||||
|
||||
|
||||
def load_fixtures(path: Path) -> list[Fixture]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(f"{path} must contain a JSON array of fixtures")
|
||||
fixtures: list[Fixture] = []
|
||||
for i, raw in enumerate(data):
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"fixture {i} is not an object")
|
||||
fixtures.append(
|
||||
Fixture(
|
||||
name=raw["name"],
|
||||
project=raw.get("project", ""),
|
||||
prompt=raw["prompt"],
|
||||
budget=int(raw.get("budget", DEFAULT_BUDGET)),
|
||||
expect_present=list(raw.get("expect_present", [])),
|
||||
expect_absent=list(raw.get("expect_absent", [])),
|
||||
notes=raw.get("notes", ""),
|
||||
)
|
||||
)
|
||||
return fixtures
|
||||
|
||||
|
||||
def run_fixture(fixture: Fixture, base_url: str, timeout: int) -> FixtureResult:
|
||||
payload = {
|
||||
"prompt": fixture.prompt,
|
||||
"project": fixture.project or None,
|
||||
"budget": fixture.budget,
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
url=f"{base_url}/context/build",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.URLError as exc:
|
||||
return FixtureResult(
|
||||
fixture=fixture,
|
||||
ok=False,
|
||||
missing_present=list(fixture.expect_present),
|
||||
unexpected_absent=[],
|
||||
total_chars=0,
|
||||
error=f"http_error: {exc}",
|
||||
)
|
||||
|
||||
formatted = body.get("formatted_context") or ""
|
||||
missing = [s for s in fixture.expect_present if s not in formatted]
|
||||
unexpected = [s for s in fixture.expect_absent if s in formatted]
|
||||
return FixtureResult(
|
||||
fixture=fixture,
|
||||
ok=not missing and not unexpected,
|
||||
missing_present=missing,
|
||||
unexpected_absent=unexpected,
|
||||
total_chars=len(formatted),
|
||||
)
|
||||
|
||||
|
||||
def print_human_report(results: list[FixtureResult]) -> None:
|
||||
total = len(results)
|
||||
passed = sum(1 for r in results if r.ok)
|
||||
print(f"Retrieval eval: {passed}/{total} fixtures passed")
|
||||
print()
|
||||
for r in results:
|
||||
marker = "PASS" if r.ok else "FAIL"
|
||||
print(f"[{marker}] {r.fixture.name} project={r.fixture.project} chars={r.total_chars}")
|
||||
if r.error:
|
||||
print(f" error: {r.error}")
|
||||
for miss in r.missing_present:
|
||||
print(f" missing expected: {miss!r}")
|
||||
for bleed in r.unexpected_absent:
|
||||
print(f" unexpected present: {bleed!r}")
|
||||
if r.fixture.notes and not r.ok:
|
||||
print(f" notes: {r.fixture.notes}")
|
||||
|
||||
|
||||
def print_json_report(results: list[FixtureResult]) -> None:
|
||||
payload = {
|
||||
"total": len(results),
|
||||
"passed": sum(1 for r in results if r.ok),
|
||||
"fixtures": [
|
||||
{
|
||||
"name": r.fixture.name,
|
||||
"project": r.fixture.project,
|
||||
"ok": r.ok,
|
||||
"total_chars": r.total_chars,
|
||||
"missing_present": r.missing_present,
|
||||
"unexpected_absent": r.unexpected_absent,
|
||||
"error": r.error,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
json.dump(payload, sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AtoCore retrieval quality eval harness")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
||||
parser.add_argument("--fixtures", type=Path, default=DEFAULT_FIXTURES)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
fixtures = load_fixtures(args.fixtures)
|
||||
results = [run_fixture(f, args.base_url, args.timeout) for f in fixtures]
|
||||
|
||||
if args.json:
|
||||
print_json_report(results)
|
||||
else:
|
||||
print_human_report(results)
|
||||
|
||||
return 0 if all(r.ok for r in results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
225
scripts/retrieval_eval_fixtures.json
Normal file
225
scripts/retrieval_eval_fixtures.json
Normal file
@@ -0,0 +1,225 @@
|
||||
[
|
||||
{
|
||||
"name": "p04-architecture-decision",
|
||||
"project": "p04-gigabit",
|
||||
"prompt": "what mirror architecture was selected for GigaBIT M1 and why",
|
||||
"expect_present": [
|
||||
"--- Trusted Project State ---",
|
||||
"Option B",
|
||||
"conical",
|
||||
"--- Project Memories ---"
|
||||
],
|
||||
"expect_absent": [
|
||||
"p06-polisher",
|
||||
"folded-beam"
|
||||
],
|
||||
"notes": "Canonical p04 decision — should surface both Trusted Project State and the project-memory band"
|
||||
},
|
||||
{
|
||||
"name": "p04-constraints",
|
||||
"project": "p04-gigabit",
|
||||
"prompt": "what are the key GigaBIT M1 program constraints",
|
||||
"expect_present": [
|
||||
"--- Trusted Project State ---",
|
||||
"Zerodur",
|
||||
"1.2"
|
||||
],
|
||||
"expect_absent": [
|
||||
"polisher suite"
|
||||
],
|
||||
"notes": "Key constraints are in Trusted Project State and in the mission-framing memory"
|
||||
},
|
||||
{
|
||||
"name": "p04-short-ambiguous",
|
||||
"project": "p04-gigabit",
|
||||
"prompt": "current status",
|
||||
"expect_present": [
|
||||
"--- Trusted Project State ---"
|
||||
],
|
||||
"expect_absent": [],
|
||||
"notes": "Short ambiguous prompt — at minimum project state should surface. Hard case: the prompt is generic enough that chunks may not rank well."
|
||||
},
|
||||
{
|
||||
"name": "p05-configuration",
|
||||
"project": "p05-interferometer",
|
||||
"prompt": "what is the selected interferometer configuration",
|
||||
"expect_present": [
|
||||
"folded-beam",
|
||||
"CGH"
|
||||
],
|
||||
"expect_absent": [
|
||||
"Option B",
|
||||
"conical back",
|
||||
"polisher suite"
|
||||
],
|
||||
"notes": "P05 architecture memory covers folded-beam + CGH. GigaBIT M1 legitimately appears in p05 source docs."
|
||||
},
|
||||
{
|
||||
"name": "p05-vendor-signal",
|
||||
"project": "p05-interferometer",
|
||||
"prompt": "what is the current vendor signal for the interferometer procurement",
|
||||
"expect_present": [
|
||||
"4D",
|
||||
"Zygo"
|
||||
],
|
||||
"expect_absent": [
|
||||
"polisher"
|
||||
],
|
||||
"notes": "Vendor memory mentions 4D as strongest technical candidate and Zygo Verifire SV as value path"
|
||||
},
|
||||
{
|
||||
"name": "p05-cgh-calibration",
|
||||
"project": "p05-interferometer",
|
||||
"prompt": "how does CGH calibration work for the interferometer",
|
||||
"expect_present": [
|
||||
"CGH"
|
||||
],
|
||||
"expect_absent": [
|
||||
"polisher-sim",
|
||||
"polisher-post"
|
||||
],
|
||||
"notes": "CGH is a core p05 concept. Should surface via chunks and possibly the architecture memory. Must not bleed p06 polisher-suite terms."
|
||||
},
|
||||
{
|
||||
"name": "p06-suite-split",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "how is the polisher software suite split across layers",
|
||||
"expect_present": [
|
||||
"polisher-sim",
|
||||
"polisher-post",
|
||||
"polisher-control"
|
||||
],
|
||||
"expect_absent": [
|
||||
"GigaBIT"
|
||||
],
|
||||
"notes": "The three-layer split is in multiple p06 memories"
|
||||
},
|
||||
{
|
||||
"name": "p06-control-rule",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "what is the polisher control design rule",
|
||||
"expect_present": [
|
||||
"interlocks"
|
||||
],
|
||||
"expect_absent": [
|
||||
"interferometer"
|
||||
],
|
||||
"notes": "Control design rule memory mentions interlocks and state transitions"
|
||||
},
|
||||
{
|
||||
"name": "p06-firmware-interface",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "what is the firmware interface contract for the polisher machine",
|
||||
"expect_present": [
|
||||
"controller-job"
|
||||
],
|
||||
"expect_absent": [
|
||||
"interferometer",
|
||||
"GigaBIT"
|
||||
],
|
||||
"notes": "New p06 memory from the first triage: firmware interface contract is invariant controller-job.v1 in, run-log.v1 out"
|
||||
},
|
||||
{
|
||||
"name": "p06-z-axis",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "how does the polisher Z-axis work",
|
||||
"expect_present": [
|
||||
"engage"
|
||||
],
|
||||
"expect_absent": [
|
||||
"interferometer"
|
||||
],
|
||||
"notes": "New p06 memory: Z-axis is binary engage/retract, not continuous position. The word 'engage' should appear."
|
||||
},
|
||||
{
|
||||
"name": "p06-cam-mechanism",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "how is cam amplitude controlled on the polisher",
|
||||
"expect_present": [
|
||||
"encoder"
|
||||
],
|
||||
"expect_absent": [
|
||||
"GigaBIT"
|
||||
],
|
||||
"notes": "New p06 memory: cam set mechanically by operator, read by encoders. The word 'encoder' should appear."
|
||||
},
|
||||
{
|
||||
"name": "p06-telemetry-rate",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "what is the expected polishing telemetry data rate",
|
||||
"expect_present": [
|
||||
"29 MB"
|
||||
],
|
||||
"expect_absent": [
|
||||
"interferometer"
|
||||
],
|
||||
"notes": "New p06 knowledge memory: approximately 29 MB per hour at 100 Hz"
|
||||
},
|
||||
{
|
||||
"name": "p06-offline-design",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "does the polisher machine need network to operate",
|
||||
"expect_present": [
|
||||
"offline"
|
||||
],
|
||||
"expect_absent": [
|
||||
"CGH"
|
||||
],
|
||||
"notes": "New p06 memory: machine works fully offline and independently; network is for remote access only"
|
||||
},
|
||||
{
|
||||
"name": "p06-short-ambiguous",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "current status",
|
||||
"expect_present": [
|
||||
"--- Trusted Project State ---"
|
||||
],
|
||||
"expect_absent": [],
|
||||
"notes": "Short ambiguous prompt — project state should surface at minimum"
|
||||
},
|
||||
{
|
||||
"name": "cross-project-no-bleed",
|
||||
"project": "p04-gigabit",
|
||||
"prompt": "what telemetry rate should we target",
|
||||
"expect_present": [],
|
||||
"expect_absent": [
|
||||
"29 MB",
|
||||
"polisher"
|
||||
],
|
||||
"notes": "Adversarial: telemetry rate is a p06 fact. A p04 query for 'telemetry rate' must NOT surface p06 memories. Tests cross-project gating."
|
||||
},
|
||||
{
|
||||
"name": "no-project-hint",
|
||||
"project": "",
|
||||
"prompt": "tell me about the current projects",
|
||||
"expect_present": [],
|
||||
"expect_absent": [
|
||||
"--- Project Memories ---"
|
||||
],
|
||||
"notes": "Without a project hint, project memories must not appear (cross-project bleed guard). Chunks may appear if any match."
|
||||
},
|
||||
{
|
||||
"name": "p06-usb-ssd",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "what storage solution is specified for the polisher RPi",
|
||||
"expect_present": [
|
||||
"USB SSD"
|
||||
],
|
||||
"expect_absent": [
|
||||
"interferometer"
|
||||
],
|
||||
"notes": "New p06 memory from triage: USB SSD mandatory, not SD card"
|
||||
},
|
||||
{
|
||||
"name": "p06-tailscale",
|
||||
"project": "p06-polisher",
|
||||
"prompt": "how do we access the polisher machine remotely",
|
||||
"expect_present": [
|
||||
"Tailscale"
|
||||
],
|
||||
"expect_absent": [
|
||||
"GigaBIT"
|
||||
],
|
||||
"notes": "New p06 memory: Tailscale mesh for RPi remote access"
|
||||
}
|
||||
]
|
||||
168
scripts/synthesize_projects.py
Normal file
168
scripts/synthesize_projects.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Weekly project synthesis — LLM-generated 'current state' paragraph per project.
|
||||
|
||||
Reads each registered project's state entries, memories, and entities,
|
||||
asks sonnet for a 3-5 sentence synthesis, and caches it under
|
||||
project_state/status/synthesis_cache. The wiki's project page reads
|
||||
this cached synthesis as the top band.
|
||||
|
||||
Runs weekly via cron (or manually). Cheap — one LLM call per project.
|
||||
|
||||
Usage:
|
||||
python3 scripts/synthesize_projects.py --base-url http://localhost:8100
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://localhost:8100")
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_SYNTHESIS_MODEL", "sonnet")
|
||||
TIMEOUT_S = 60
|
||||
|
||||
SYSTEM_PROMPT = """You are summarizing the current state of an engineering project for a personal context engine called AtoCore.
|
||||
|
||||
You will receive:
|
||||
- Project state entries (decisions, requirements, status)
|
||||
- Active memories tagged to this project
|
||||
- Entity graph (subsystems, components, materials, decisions)
|
||||
|
||||
Write a 3-5 sentence synthesis covering:
|
||||
1. What the project is and its current stage
|
||||
2. The key locked-in decisions and architecture
|
||||
3. What the next focus is
|
||||
|
||||
Rules:
|
||||
- Plain prose, no bullet lists
|
||||
- Factual, grounded in what the data says — don't invent or speculate
|
||||
- Present tense
|
||||
- Under 500 characters total
|
||||
- No markdown formatting, just prose
|
||||
- If the data is sparse, say so honestly ("limited project data available")
|
||||
|
||||
Output ONLY the synthesis paragraph. No preamble, no JSON, no markdown headers."""
|
||||
|
||||
|
||||
_cwd = None
|
||||
|
||||
|
||||
def get_cwd():
|
||||
global _cwd
|
||||
if _cwd is None:
|
||||
_cwd = tempfile.mkdtemp(prefix="ato-synth-")
|
||||
return _cwd
|
||||
|
||||
|
||||
def api_get(base_url, path):
|
||||
with urllib.request.urlopen(f"{base_url}{path}", timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def api_post(base_url, path, body):
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}{path}", method="POST",
|
||||
headers={"Content-Type": "application/json"}, data=data,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def synthesize_project(base_url, project_id, model):
|
||||
# Gather context
|
||||
state = api_get(base_url, f"/project/state/{project_id}").get("entries", [])
|
||||
memories = api_get(base_url, f"/memory?project={project_id}&active_only=true&limit=20").get("memories", [])
|
||||
entities = api_get(base_url, f"/entities?project={project_id}&limit=50").get("entities", [])
|
||||
|
||||
if not (state or memories or entities):
|
||||
return None
|
||||
|
||||
lines = [f"PROJECT: {project_id}\n"]
|
||||
if state:
|
||||
lines.append("STATE ENTRIES:")
|
||||
for e in state[:15]:
|
||||
if e.get("key") == "synthesis_cache":
|
||||
continue
|
||||
lines.append(f" [{e['category']}] {e['key']}: {e['value'][:200]}")
|
||||
|
||||
if memories:
|
||||
lines.append("\nACTIVE MEMORIES:")
|
||||
for m in memories[:10]:
|
||||
lines.append(f" [{m['memory_type']}] {m['content'][:200]}")
|
||||
|
||||
if entities:
|
||||
lines.append("\nENTITIES:")
|
||||
by_type = {}
|
||||
for e in entities:
|
||||
by_type.setdefault(e["entity_type"], []).append(e["name"])
|
||||
for t, names in by_type.items():
|
||||
lines.append(f" {t}: {', '.join(names[:8])}")
|
||||
|
||||
user_msg = "\n".join(lines) + "\n\nWrite the synthesis paragraph now."
|
||||
|
||||
if not shutil.which("claude"):
|
||||
print(f" ! claude CLI not available, skipping {project_id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "-p", "--model", model,
|
||||
"--append-system-prompt", SYSTEM_PROMPT,
|
||||
"--disable-slash-commands",
|
||||
user_msg],
|
||||
capture_output=True, text=True, timeout=TIMEOUT_S,
|
||||
cwd=get_cwd(), encoding="utf-8", errors="replace",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ! subprocess failed for {project_id}: {e}")
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" ! claude exit {result.returncode} for {project_id}")
|
||||
return None
|
||||
|
||||
synthesis = (result.stdout or "").strip()
|
||||
if not synthesis or len(synthesis) < 50:
|
||||
return None
|
||||
return synthesis[:1000]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
parser.add_argument("--project", default=None, help="single project to synthesize")
|
||||
args = parser.parse_args()
|
||||
|
||||
projects = api_get(args.base_url, "/projects").get("projects", [])
|
||||
if args.project:
|
||||
projects = [p for p in projects if p["id"] == args.project]
|
||||
|
||||
print(f"Synthesizing {len(projects)} project(s) with {args.model}...")
|
||||
|
||||
for p in projects:
|
||||
pid = p["id"]
|
||||
print(f"\n- {pid}")
|
||||
synthesis = synthesize_project(args.base_url, pid, args.model)
|
||||
if synthesis:
|
||||
print(f" {synthesis[:200]}...")
|
||||
try:
|
||||
api_post(args.base_url, "/project/state", {
|
||||
"project": pid,
|
||||
"category": "status",
|
||||
"key": "synthesis_cache",
|
||||
"value": synthesis,
|
||||
"source": "weekly synthesis pass",
|
||||
})
|
||||
print(f" + cached")
|
||||
except Exception as e:
|
||||
print(f" ! save failed: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,6 +3,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
import atocore.config as _config
|
||||
@@ -30,11 +31,32 @@ from atocore.interactions.service import (
|
||||
list_interactions,
|
||||
record_interaction,
|
||||
)
|
||||
from atocore.engineering.mirror import generate_project_overview
|
||||
from atocore.engineering.wiki import (
|
||||
render_entity,
|
||||
render_homepage,
|
||||
render_project,
|
||||
render_search,
|
||||
)
|
||||
from atocore.engineering.service import (
|
||||
ENTITY_TYPES,
|
||||
RELATIONSHIP_TYPES,
|
||||
create_entity,
|
||||
create_relationship,
|
||||
get_entities,
|
||||
get_entity,
|
||||
get_entity_with_context,
|
||||
get_relationships,
|
||||
)
|
||||
from atocore.memory.extractor import (
|
||||
EXTRACTOR_VERSION,
|
||||
MemoryCandidate,
|
||||
extract_candidates_from_interaction,
|
||||
)
|
||||
from atocore.memory.extractor_llm import (
|
||||
LLM_EXTRACTOR_VERSION,
|
||||
extract_candidates_llm,
|
||||
)
|
||||
from atocore.memory.reinforcement import reinforce_from_interaction
|
||||
from atocore.memory.service import (
|
||||
MEMORY_STATUSES,
|
||||
@@ -49,6 +71,7 @@ from atocore.memory.service import (
|
||||
)
|
||||
from atocore.observability.logger import get_logger
|
||||
from atocore.ops.backup import (
|
||||
cleanup_old_backups,
|
||||
create_runtime_backup,
|
||||
list_runtime_backups,
|
||||
validate_backup,
|
||||
@@ -68,6 +91,33 @@ router = APIRouter()
|
||||
log = get_logger("api")
|
||||
|
||||
|
||||
# --- Wiki routes (HTML, served first for clean URLs) ---
|
||||
|
||||
|
||||
@router.get("/wiki", response_class=HTMLResponse)
|
||||
def wiki_home() -> HTMLResponse:
|
||||
return HTMLResponse(content=render_homepage())
|
||||
|
||||
|
||||
@router.get("/wiki/projects/{project_name}", response_class=HTMLResponse)
|
||||
def wiki_project(project_name: str) -> HTMLResponse:
|
||||
from atocore.projects.registry import resolve_project_name as _resolve
|
||||
return HTMLResponse(content=render_project(_resolve(project_name)))
|
||||
|
||||
|
||||
@router.get("/wiki/entities/{entity_id}", response_class=HTMLResponse)
|
||||
def wiki_entity(entity_id: str) -> HTMLResponse:
|
||||
html = render_entity(entity_id)
|
||||
if html is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@router.get("/wiki/search", response_class=HTMLResponse)
|
||||
def wiki_search(q: str = "") -> HTMLResponse:
|
||||
return HTMLResponse(content=render_search(q))
|
||||
|
||||
|
||||
# --- Request/Response models ---
|
||||
|
||||
|
||||
@@ -140,6 +190,7 @@ class MemoryCreateRequest(BaseModel):
|
||||
content: str
|
||||
project: str = ""
|
||||
confidence: float = 1.0
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class MemoryUpdateRequest(BaseModel):
|
||||
@@ -343,6 +394,7 @@ def api_create_memory(req: MemoryCreateRequest) -> dict:
|
||||
content=req.content,
|
||||
project=req.project,
|
||||
confidence=req.confidence,
|
||||
status=req.status,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -511,6 +563,7 @@ class InteractionRecordRequest(BaseModel):
|
||||
chunks_used: list[str] = []
|
||||
context_pack: dict | None = None
|
||||
reinforce: bool = True
|
||||
extract: bool = False
|
||||
|
||||
|
||||
@router.post("/interactions")
|
||||
@@ -536,6 +589,7 @@ def api_record_interaction(req: InteractionRecordRequest) -> dict:
|
||||
chunks_used=req.chunks_used,
|
||||
context_pack=req.context_pack,
|
||||
reinforce=req.reinforce,
|
||||
extract=req.extract,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -575,6 +629,7 @@ def api_reinforce_interaction(interaction_id: str) -> dict:
|
||||
|
||||
class InteractionExtractRequest(BaseModel):
|
||||
persist: bool = False
|
||||
mode: str = "rule" # "rule" or "llm"
|
||||
|
||||
|
||||
@router.post("/interactions/{interaction_id}/extract")
|
||||
@@ -596,7 +651,10 @@ def api_extract_from_interaction(
|
||||
if interaction is None:
|
||||
raise HTTPException(status_code=404, detail=f"Interaction not found: {interaction_id}")
|
||||
payload = req or InteractionExtractRequest()
|
||||
candidates: list[MemoryCandidate] = extract_candidates_from_interaction(interaction)
|
||||
if payload.mode == "llm":
|
||||
candidates: list[MemoryCandidate] = extract_candidates_llm(interaction)
|
||||
else:
|
||||
candidates: list[MemoryCandidate] = extract_candidates_from_interaction(interaction)
|
||||
|
||||
persisted_ids: list[str] = []
|
||||
if payload.persist:
|
||||
@@ -731,6 +789,411 @@ def api_list_backups() -> dict:
|
||||
}
|
||||
|
||||
|
||||
class BackupCleanupRequest(BaseModel):
|
||||
confirm: bool = False
|
||||
|
||||
|
||||
@router.post("/admin/backup/cleanup")
|
||||
def api_cleanup_backups(req: BackupCleanupRequest | None = None) -> dict:
|
||||
"""Apply retention policy to old backup snapshots.
|
||||
|
||||
Dry-run by default. Pass ``confirm: true`` to actually delete.
|
||||
Retention: last 7 daily, last 4 weekly (Sundays), last 6 monthly (1st).
|
||||
"""
|
||||
payload = req or BackupCleanupRequest()
|
||||
try:
|
||||
return cleanup_old_backups(confirm=payload.confirm)
|
||||
except Exception as e:
|
||||
log.error("admin_cleanup_failed", error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Cleanup failed: {e}")
|
||||
|
||||
|
||||
class ExtractBatchRequest(BaseModel):
|
||||
since: str | None = None
|
||||
mode: str = "llm"
|
||||
limit: int = 50
|
||||
persist: bool = True
|
||||
|
||||
|
||||
@router.post("/admin/extract-batch")
|
||||
def api_extract_batch(req: ExtractBatchRequest | None = None) -> dict:
|
||||
"""Run batch extraction across recent interactions.
|
||||
|
||||
Fetches interactions since ``since`` (or since the last recorded
|
||||
batch run), runs the extractor (rule or LLM) on each, and persists
|
||||
any candidates as ``status=candidate``. The last-run timestamp is
|
||||
stored in project state under ``atocore / status /
|
||||
last_extract_batch_run`` so subsequent calls without ``since``
|
||||
automatically pick up where the last run left off.
|
||||
|
||||
This endpoint is the operational home for R1 / R5 — it makes the
|
||||
LLM extractor accessible as an API operation rather than a
|
||||
script-only eval tool. Still NOT on the capture hot path: callers
|
||||
invoke this endpoint explicitly (cron, manual curl, CLI).
|
||||
"""
|
||||
payload = req or ExtractBatchRequest()
|
||||
since = payload.since
|
||||
|
||||
if not since:
|
||||
state_entries = get_state("atocore")
|
||||
for entry in state_entries:
|
||||
if entry.category == "status" and entry.key == "last_extract_batch_run":
|
||||
since = entry.value
|
||||
break
|
||||
|
||||
interactions = list_interactions(since=since, limit=min(payload.limit, 200))
|
||||
|
||||
processed = 0
|
||||
total_candidates = 0
|
||||
total_persisted = 0
|
||||
errors: list[dict] = []
|
||||
|
||||
for interaction in interactions:
|
||||
if not (interaction.response or interaction.response_summary):
|
||||
continue
|
||||
try:
|
||||
if payload.mode == "llm":
|
||||
candidates = extract_candidates_llm(interaction)
|
||||
else:
|
||||
candidates = extract_candidates_from_interaction(interaction)
|
||||
except Exception as exc:
|
||||
errors.append({"interaction_id": interaction.id, "error": str(exc)})
|
||||
continue
|
||||
|
||||
processed += 1
|
||||
total_candidates += len(candidates)
|
||||
|
||||
if payload.persist and candidates:
|
||||
for candidate in candidates:
|
||||
try:
|
||||
create_memory(
|
||||
memory_type=candidate.memory_type,
|
||||
content=candidate.content,
|
||||
project=candidate.project,
|
||||
confidence=candidate.confidence,
|
||||
status="candidate",
|
||||
)
|
||||
total_persisted += 1
|
||||
except ValueError:
|
||||
pass # duplicate — skip silently
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
try:
|
||||
set_state(
|
||||
project="atocore",
|
||||
category="status",
|
||||
key="last_extract_batch_run",
|
||||
value=now,
|
||||
source="admin/extract-batch endpoint",
|
||||
)
|
||||
except Exception:
|
||||
pass # best-effort timestamp tracking
|
||||
|
||||
log.info(
|
||||
"extract_batch_complete",
|
||||
mode=payload.mode,
|
||||
processed=processed,
|
||||
total_candidates=total_candidates,
|
||||
total_persisted=total_persisted,
|
||||
errors=len(errors),
|
||||
)
|
||||
|
||||
return {
|
||||
"processed": processed,
|
||||
"total_candidates": total_candidates,
|
||||
"total_persisted": total_persisted,
|
||||
"mode": payload.mode,
|
||||
"persist": payload.persist,
|
||||
"since": since or "(first run)",
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/dashboard")
|
||||
def api_dashboard() -> dict:
|
||||
"""One-shot system observability dashboard.
|
||||
|
||||
Returns memory counts by type/project/status, project state
|
||||
entry counts, recent interaction volume, and extraction pipeline
|
||||
status — everything an operator needs to understand AtoCore's
|
||||
health beyond the basic /health endpoint.
|
||||
"""
|
||||
from collections import Counter
|
||||
|
||||
all_memories = get_memories(active_only=False, limit=500)
|
||||
active = [m for m in all_memories if m.status == "active"]
|
||||
candidates = [m for m in all_memories if m.status == "candidate"]
|
||||
|
||||
type_counts = dict(Counter(m.memory_type for m in active))
|
||||
project_counts = dict(Counter(m.project or "(none)" for m in active))
|
||||
reinforced = [m for m in active if m.reference_count > 0]
|
||||
|
||||
interactions = list_interactions(limit=1)
|
||||
recent_interaction = interactions[0].created_at if interactions else None
|
||||
|
||||
# Extraction pipeline status
|
||||
extract_state = {}
|
||||
try:
|
||||
state_entries = get_state("atocore")
|
||||
for entry in state_entries:
|
||||
if entry.category == "status" and entry.key == "last_extract_batch_run":
|
||||
extract_state["last_run"] = entry.value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Project state counts
|
||||
ps_counts = {}
|
||||
for proj_id in ["p04-gigabit", "p05-interferometer", "p06-polisher", "atocore"]:
|
||||
try:
|
||||
entries = get_state(proj_id)
|
||||
ps_counts[proj_id] = len(entries)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"memories": {
|
||||
"active": len(active),
|
||||
"candidates": len(candidates),
|
||||
"by_type": type_counts,
|
||||
"by_project": project_counts,
|
||||
"reinforced": len(reinforced),
|
||||
},
|
||||
"project_state": {
|
||||
"counts": ps_counts,
|
||||
"total": sum(ps_counts.values()),
|
||||
},
|
||||
"interactions": {
|
||||
"most_recent": recent_interaction,
|
||||
},
|
||||
"extraction_pipeline": extract_state,
|
||||
}
|
||||
|
||||
|
||||
# --- Engineering Knowledge Layer (Layer 2) ---
|
||||
|
||||
|
||||
class EntityCreateRequest(BaseModel):
|
||||
entity_type: str
|
||||
name: str
|
||||
project: str = ""
|
||||
description: str = ""
|
||||
properties: dict | None = None
|
||||
status: str = "active"
|
||||
confidence: float = 1.0
|
||||
source_refs: list[str] | None = None
|
||||
|
||||
|
||||
class RelationshipCreateRequest(BaseModel):
|
||||
source_entity_id: str
|
||||
target_entity_id: str
|
||||
relationship_type: str
|
||||
confidence: float = 1.0
|
||||
source_refs: list[str] | None = None
|
||||
|
||||
|
||||
@router.post("/entities")
|
||||
def api_create_entity(req: EntityCreateRequest) -> dict:
|
||||
"""Create a new engineering entity."""
|
||||
try:
|
||||
entity = create_entity(
|
||||
entity_type=req.entity_type,
|
||||
name=req.name,
|
||||
project=req.project,
|
||||
description=req.description,
|
||||
properties=req.properties,
|
||||
status=req.status,
|
||||
confidence=req.confidence,
|
||||
source_refs=req.source_refs,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"status": "ok", "id": entity.id, "entity_type": entity.entity_type, "name": entity.name}
|
||||
|
||||
|
||||
@router.get("/entities")
|
||||
def api_list_entities(
|
||||
entity_type: str | None = None,
|
||||
project: str | None = None,
|
||||
status: str = "active",
|
||||
name_contains: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict:
|
||||
"""List engineering entities with optional filters."""
|
||||
entities = get_entities(
|
||||
entity_type=entity_type,
|
||||
project=project,
|
||||
status=status,
|
||||
name_contains=name_contains,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"entities": [
|
||||
{
|
||||
"id": e.id,
|
||||
"entity_type": e.entity_type,
|
||||
"name": e.name,
|
||||
"project": e.project,
|
||||
"description": e.description,
|
||||
"properties": e.properties,
|
||||
"status": e.status,
|
||||
"confidence": e.confidence,
|
||||
}
|
||||
for e in entities
|
||||
],
|
||||
"count": len(entities),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}")
|
||||
def api_get_entity(entity_id: str) -> dict:
|
||||
"""Get an entity with its relationships and related entities."""
|
||||
result = get_entity_with_context(entity_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: {entity_id}")
|
||||
entity = result["entity"]
|
||||
return {
|
||||
"entity": {
|
||||
"id": entity.id,
|
||||
"entity_type": entity.entity_type,
|
||||
"name": entity.name,
|
||||
"project": entity.project,
|
||||
"description": entity.description,
|
||||
"properties": entity.properties,
|
||||
"status": entity.status,
|
||||
"confidence": entity.confidence,
|
||||
"source_refs": entity.source_refs,
|
||||
"created_at": entity.created_at,
|
||||
"updated_at": entity.updated_at,
|
||||
},
|
||||
"relationships": [
|
||||
{
|
||||
"id": r.id,
|
||||
"source_entity_id": r.source_entity_id,
|
||||
"target_entity_id": r.target_entity_id,
|
||||
"relationship_type": r.relationship_type,
|
||||
"confidence": r.confidence,
|
||||
}
|
||||
for r in result["relationships"]
|
||||
],
|
||||
"related_entities": {
|
||||
eid: {
|
||||
"entity_type": e.entity_type,
|
||||
"name": e.name,
|
||||
"project": e.project,
|
||||
"description": e.description[:200],
|
||||
}
|
||||
for eid, e in result["related_entities"].items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/relationships")
|
||||
def api_create_relationship(req: RelationshipCreateRequest) -> dict:
|
||||
"""Create a relationship between two entities."""
|
||||
try:
|
||||
rel = create_relationship(
|
||||
source_entity_id=req.source_entity_id,
|
||||
target_entity_id=req.target_entity_id,
|
||||
relationship_type=req.relationship_type,
|
||||
confidence=req.confidence,
|
||||
source_refs=req.source_refs,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {
|
||||
"status": "ok",
|
||||
"id": rel.id,
|
||||
"relationship_type": rel.relationship_type,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects/{project_name}/mirror.html", response_class=HTMLResponse)
|
||||
def api_project_mirror_html(project_name: str) -> HTMLResponse:
|
||||
"""Serve a readable HTML project overview page.
|
||||
|
||||
Open in a browser for a clean, styled project dashboard derived
|
||||
from AtoCore's structured data. Source of truth is the database —
|
||||
this page is a derived view.
|
||||
"""
|
||||
from atocore.projects.registry import resolve_project_name as _resolve
|
||||
|
||||
canonical = _resolve(project_name)
|
||||
try:
|
||||
md_content = generate_project_overview(canonical)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Mirror generation failed: {e}")
|
||||
|
||||
import markdown
|
||||
|
||||
html_body = markdown.markdown(md_content, extensions=["tables", "fenced_code"])
|
||||
html = _MIRROR_HTML_TEMPLATE.replace("{{title}}", f"{canonical} — AtoCore Mirror")
|
||||
html = html.replace("{{body}}", html_body)
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
_MIRROR_HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
:root { --bg: #fafafa; --text: #1a1a2e; --accent: #2563eb; --border: #e2e8f0; --card: #fff; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg: #0f172a; --text: #e2e8f0; --accent: #60a5fa; --border: #334155; --card: #1e293b; }
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.7; color: var(--text); background: var(--bg);
|
||||
max-width: 800px; margin: 0 auto; padding: 2rem 1.5rem;
|
||||
}
|
||||
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: var(--accent); }
|
||||
h2 { font-size: 1.4rem; margin-top: 2.5rem; margin-bottom: 0.8rem; padding-bottom: 0.3rem; border-bottom: 2px solid var(--border); }
|
||||
h3 { font-size: 1.15rem; margin-top: 1.5rem; margin-bottom: 0.5rem; }
|
||||
p { margin-bottom: 0.8rem; }
|
||||
ul { margin-left: 1.5rem; margin-bottom: 1rem; }
|
||||
li { margin-bottom: 0.4rem; }
|
||||
li ul { margin-top: 0.3rem; }
|
||||
strong { color: var(--accent); font-weight: 600; }
|
||||
em { opacity: 0.7; font-size: 0.9em; }
|
||||
blockquote {
|
||||
background: var(--card); border-left: 4px solid var(--accent);
|
||||
padding: 0.8rem 1.2rem; margin: 1rem 0; border-radius: 0 8px 8px 0;
|
||||
}
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
|
||||
code { background: var(--card); padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.9em; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{body}}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/projects/{project_name}/mirror")
|
||||
def api_project_mirror(project_name: str) -> dict:
|
||||
"""Generate a human-readable project overview from structured data.
|
||||
|
||||
Layer 3 of the AtoCore architecture. The mirror is DERIVED from
|
||||
entities, project state, and memories — it is not canonical truth.
|
||||
Returns markdown that can be rendered, saved to a file, or served
|
||||
as a dashboard page.
|
||||
"""
|
||||
from atocore.projects.registry import resolve_project_name as _resolve
|
||||
|
||||
canonical = _resolve(project_name)
|
||||
try:
|
||||
markdown = generate_project_overview(canonical)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Mirror generation failed: {e}")
|
||||
return {"project": canonical, "format": "markdown", "content": markdown}
|
||||
|
||||
|
||||
@router.get("/admin/backup/{stamp}/validate")
|
||||
def api_validate_backup(stamp: str) -> dict:
|
||||
"""Validate that a previously created backup is structurally usable."""
|
||||
|
||||
@@ -14,6 +14,7 @@ import atocore.config as _config
|
||||
from atocore.context.project_state import format_project_state, get_state
|
||||
from atocore.memory.service import get_memories_for_context
|
||||
from atocore.observability.logger import get_logger
|
||||
from atocore.engineering.service import get_entities, get_entity_with_context
|
||||
from atocore.projects.registry import resolve_project_name
|
||||
from atocore.retrieval.retriever import ChunkResult, retrieve
|
||||
|
||||
@@ -29,7 +30,20 @@ SYSTEM_PREFIX = (
|
||||
# Budget allocation (per Master Plan section 9):
|
||||
# identity: 5%, preferences: 5%, project state: 20%, retrieval: 60%+
|
||||
PROJECT_STATE_BUDGET_RATIO = 0.20
|
||||
MEMORY_BUDGET_RATIO = 0.10 # 5% identity + 5% preference
|
||||
MEMORY_BUDGET_RATIO = 0.05 # identity + preference; lowered from 0.10 to avoid squeezing project memories and chunks
|
||||
# Project-scoped memories (project/knowledge/episodic) are the outlet
|
||||
# for the Phase 9 reflection loop on the retrieval side. Budget sits
|
||||
# between identity/preference and retrieved chunks so a reinforced
|
||||
# memory can actually reach the model.
|
||||
PROJECT_MEMORY_BUDGET_RATIO = 0.25
|
||||
PROJECT_MEMORY_TYPES = ["project", "knowledge", "episodic"]
|
||||
# General domain knowledge — unscoped memories (project="") that surface
|
||||
# in every context pack regardless of project hint. These are earned
|
||||
# engineering insights that apply across projects (e.g., "Preston removal
|
||||
# model breaks down below 5N because the contact assumption fails").
|
||||
DOMAIN_KNOWLEDGE_BUDGET_RATIO = 0.10
|
||||
DOMAIN_KNOWLEDGE_TYPES = ["knowledge"]
|
||||
ENGINEERING_CONTEXT_BUDGET_RATIO = 0.10
|
||||
|
||||
# Last built context pack for debug inspection
|
||||
_last_context_pack: "ContextPack | None" = None
|
||||
@@ -51,6 +65,12 @@ class ContextPack:
|
||||
project_state_chars: int = 0
|
||||
memory_text: str = ""
|
||||
memory_chars: int = 0
|
||||
project_memory_text: str = ""
|
||||
project_memory_chars: int = 0
|
||||
domain_knowledge_text: str = ""
|
||||
domain_knowledge_chars: int = 0
|
||||
engineering_context_text: str = ""
|
||||
engineering_context_chars: int = 0
|
||||
total_chars: int = 0
|
||||
budget: int = 0
|
||||
budget_remaining: int = 0
|
||||
@@ -107,10 +127,70 @@ def build_context(
|
||||
memory_text, memory_chars = get_memories_for_context(
|
||||
memory_types=["identity", "preference"],
|
||||
budget=memory_budget,
|
||||
query=user_prompt,
|
||||
)
|
||||
|
||||
# 2b. Get project-scoped memories (third precedence). Only
|
||||
# populated when a canonical project is in scope — cross-project
|
||||
# memory bleed would rot the pack. Active-only filtering is
|
||||
# handled by the shared min_confidence=0.5 gate inside
|
||||
# get_memories_for_context.
|
||||
project_memory_text = ""
|
||||
project_memory_chars = 0
|
||||
if canonical_project:
|
||||
project_memory_budget = min(
|
||||
int(budget * PROJECT_MEMORY_BUDGET_RATIO),
|
||||
max(budget - project_state_chars - memory_chars, 0),
|
||||
)
|
||||
project_memory_text, project_memory_chars = get_memories_for_context(
|
||||
memory_types=PROJECT_MEMORY_TYPES,
|
||||
project=canonical_project,
|
||||
budget=project_memory_budget,
|
||||
header="--- Project Memories ---",
|
||||
footer="--- End Project Memories ---",
|
||||
query=user_prompt,
|
||||
)
|
||||
|
||||
# 2c. Domain knowledge — cross-project earned insight with project=""
|
||||
# that surfaces regardless of which project the query is about.
|
||||
domain_knowledge_text = ""
|
||||
domain_knowledge_chars = 0
|
||||
domain_budget = min(
|
||||
int(budget * DOMAIN_KNOWLEDGE_BUDGET_RATIO),
|
||||
max(budget - project_state_chars - memory_chars - project_memory_chars, 0),
|
||||
)
|
||||
if domain_budget > 0:
|
||||
domain_knowledge_text, domain_knowledge_chars = get_memories_for_context(
|
||||
memory_types=DOMAIN_KNOWLEDGE_TYPES,
|
||||
project="",
|
||||
budget=domain_budget,
|
||||
header="--- Domain Knowledge ---",
|
||||
footer="--- End Domain Knowledge ---",
|
||||
query=user_prompt,
|
||||
)
|
||||
|
||||
# 2d. Engineering context — structured entity/relationship data
|
||||
# when the query matches a known entity name.
|
||||
engineering_context_text = ""
|
||||
engineering_context_chars = 0
|
||||
if canonical_project:
|
||||
eng_budget = min(
|
||||
int(budget * ENGINEERING_CONTEXT_BUDGET_RATIO),
|
||||
max(budget - project_state_chars - memory_chars
|
||||
- project_memory_chars - domain_knowledge_chars, 0),
|
||||
)
|
||||
if eng_budget > 0:
|
||||
engineering_context_text = _build_engineering_context(
|
||||
user_prompt, canonical_project, eng_budget,
|
||||
)
|
||||
engineering_context_chars = len(engineering_context_text)
|
||||
|
||||
# 3. Calculate remaining budget for retrieval
|
||||
retrieval_budget = budget - project_state_chars - memory_chars
|
||||
retrieval_budget = (
|
||||
budget - project_state_chars - memory_chars
|
||||
- project_memory_chars - domain_knowledge_chars
|
||||
- engineering_context_chars
|
||||
)
|
||||
|
||||
# 4. Retrieve candidates
|
||||
candidates = (
|
||||
@@ -130,11 +210,17 @@ def build_context(
|
||||
selected = _select_within_budget(scored, max(retrieval_budget, 0))
|
||||
|
||||
# 7. Format full context
|
||||
formatted = _format_full_context(project_state_text, memory_text, selected)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, selected,
|
||||
)
|
||||
if len(formatted) > budget:
|
||||
formatted, selected = _trim_context_to_budget(
|
||||
project_state_text,
|
||||
memory_text,
|
||||
project_memory_text,
|
||||
domain_knowledge_text,
|
||||
engineering_context_text,
|
||||
selected,
|
||||
budget,
|
||||
)
|
||||
@@ -144,6 +230,9 @@ def build_context(
|
||||
|
||||
project_state_chars = len(project_state_text)
|
||||
memory_chars = len(memory_text)
|
||||
project_memory_chars = len(project_memory_text)
|
||||
domain_knowledge_chars = len(domain_knowledge_text)
|
||||
engineering_context_chars = len(engineering_context_text)
|
||||
retrieval_chars = sum(c.char_count for c in selected)
|
||||
total_chars = len(formatted)
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
@@ -154,6 +243,12 @@ def build_context(
|
||||
project_state_chars=project_state_chars,
|
||||
memory_text=memory_text,
|
||||
memory_chars=memory_chars,
|
||||
project_memory_text=project_memory_text,
|
||||
project_memory_chars=project_memory_chars,
|
||||
domain_knowledge_text=domain_knowledge_text,
|
||||
domain_knowledge_chars=domain_knowledge_chars,
|
||||
engineering_context_text=engineering_context_text,
|
||||
engineering_context_chars=engineering_context_chars,
|
||||
total_chars=total_chars,
|
||||
budget=budget,
|
||||
budget_remaining=budget - total_chars,
|
||||
@@ -171,6 +266,9 @@ def build_context(
|
||||
chunks_used=len(selected),
|
||||
project_state_chars=project_state_chars,
|
||||
memory_chars=memory_chars,
|
||||
project_memory_chars=project_memory_chars,
|
||||
domain_knowledge_chars=domain_knowledge_chars,
|
||||
engineering_context_chars=engineering_context_chars,
|
||||
retrieval_chars=retrieval_chars,
|
||||
total_chars=total_chars,
|
||||
budget_remaining=budget - total_chars,
|
||||
@@ -250,7 +348,10 @@ def _select_within_budget(
|
||||
def _format_full_context(
|
||||
project_state_text: str,
|
||||
memory_text: str,
|
||||
chunks: list[ContextChunk],
|
||||
project_memory_text: str,
|
||||
domain_knowledge_text: str,
|
||||
engineering_context_text: str = "",
|
||||
chunks: list[ContextChunk] | None = None,
|
||||
) -> str:
|
||||
"""Format project state + memories + retrieved chunks into full context block."""
|
||||
parts = []
|
||||
@@ -265,7 +366,22 @@ def _format_full_context(
|
||||
parts.append(memory_text)
|
||||
parts.append("")
|
||||
|
||||
# 3. Retrieved chunks (lowest trust)
|
||||
# 3. Project-scoped memories (third trust level)
|
||||
if project_memory_text:
|
||||
parts.append(project_memory_text)
|
||||
parts.append("")
|
||||
|
||||
# 4. Domain knowledge (cross-project earned insight)
|
||||
if domain_knowledge_text:
|
||||
parts.append(domain_knowledge_text)
|
||||
parts.append("")
|
||||
|
||||
# 5. Engineering context (structured entity/relationship data)
|
||||
if engineering_context_text:
|
||||
parts.append(engineering_context_text)
|
||||
parts.append("")
|
||||
|
||||
# 6. Retrieved chunks (lowest trust)
|
||||
if chunks:
|
||||
parts.append("--- AtoCore Retrieved Context ---")
|
||||
if project_state_text:
|
||||
@@ -277,7 +393,7 @@ def _format_full_context(
|
||||
parts.append(chunk.content)
|
||||
parts.append("")
|
||||
parts.append("--- End Context ---")
|
||||
elif not project_state_text and not memory_text:
|
||||
elif not project_state_text and not memory_text and not project_memory_text and not domain_knowledge_text and not engineering_context_text:
|
||||
parts.append("--- AtoCore Context ---\nNo relevant context found.\n--- End Context ---")
|
||||
|
||||
return "\n".join(parts)
|
||||
@@ -299,6 +415,8 @@ def _pack_to_dict(pack: ContextPack) -> dict:
|
||||
"project_hint": pack.project_hint,
|
||||
"project_state_chars": pack.project_state_chars,
|
||||
"memory_chars": pack.memory_chars,
|
||||
"project_memory_chars": pack.project_memory_chars,
|
||||
"domain_knowledge_chars": pack.domain_knowledge_chars,
|
||||
"chunks_used": len(pack.chunks_used),
|
||||
"total_chars": pack.total_chars,
|
||||
"budget": pack.budget,
|
||||
@@ -306,6 +424,9 @@ def _pack_to_dict(pack: ContextPack) -> dict:
|
||||
"duration_ms": pack.duration_ms,
|
||||
"has_project_state": bool(pack.project_state_text),
|
||||
"has_memories": bool(pack.memory_text),
|
||||
"has_project_memories": bool(pack.project_memory_text),
|
||||
"has_domain_knowledge": bool(pack.domain_knowledge_text),
|
||||
"has_engineering_context": bool(pack.engineering_context_text),
|
||||
"chunks": [
|
||||
{
|
||||
"source_file": c.source_file,
|
||||
@@ -319,6 +440,83 @@ def _pack_to_dict(pack: ContextPack) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _build_engineering_context(
|
||||
query: str,
|
||||
project: str,
|
||||
budget: int,
|
||||
) -> str:
|
||||
"""Find entities matching the query and format their context.
|
||||
|
||||
Uses simple word-overlap matching between query tokens and entity
|
||||
names to find relevant entities, then formats the top match with
|
||||
its relationships as a compact text band.
|
||||
"""
|
||||
if budget < 100:
|
||||
return ""
|
||||
|
||||
from atocore.memory.reinforcement import _normalize, _tokenize
|
||||
|
||||
query_tokens = _tokenize(_normalize(query))
|
||||
if not query_tokens:
|
||||
return ""
|
||||
|
||||
try:
|
||||
entities = get_entities(project=project, limit=100)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not entities:
|
||||
return ""
|
||||
|
||||
scored: list[tuple[int, "Entity"]] = []
|
||||
for ent in entities:
|
||||
name_tokens = _tokenize(_normalize(ent.name))
|
||||
desc_tokens = _tokenize(_normalize(ent.description))
|
||||
overlap = len(query_tokens & (name_tokens | desc_tokens))
|
||||
if overlap > 0:
|
||||
scored.append((overlap, ent))
|
||||
|
||||
if not scored:
|
||||
return ""
|
||||
|
||||
scored.sort(key=lambda t: t[0], reverse=True)
|
||||
best_entity = scored[0][1]
|
||||
|
||||
try:
|
||||
ctx = get_entity_with_context(best_entity.id)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if ctx is None:
|
||||
return ""
|
||||
|
||||
lines = ["--- Engineering Context ---"]
|
||||
lines.append(f"[{best_entity.entity_type}] {best_entity.name}")
|
||||
if best_entity.description:
|
||||
lines.append(f" {best_entity.description[:150]}")
|
||||
|
||||
for rel in ctx["relationships"][:8]:
|
||||
other_id = (
|
||||
rel.target_entity_id
|
||||
if rel.source_entity_id == best_entity.id
|
||||
else rel.source_entity_id
|
||||
)
|
||||
other = ctx["related_entities"].get(other_id)
|
||||
if other:
|
||||
direction = "->" if rel.source_entity_id == best_entity.id else "<-"
|
||||
lines.append(
|
||||
f" {direction} {rel.relationship_type} [{other.entity_type}] {other.name}"
|
||||
)
|
||||
|
||||
lines.append("--- End Engineering Context ---")
|
||||
text = "\n".join(lines)
|
||||
|
||||
if len(text) > budget:
|
||||
text = text[:budget - 3].rstrip() + "..."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _truncate_text_block(text: str, budget: int) -> tuple[str, int]:
|
||||
"""Trim a formatted text block so trusted tiers cannot exceed the total budget."""
|
||||
if budget <= 0 or not text:
|
||||
@@ -335,26 +533,67 @@ def _truncate_text_block(text: str, budget: int) -> tuple[str, int]:
|
||||
def _trim_context_to_budget(
|
||||
project_state_text: str,
|
||||
memory_text: str,
|
||||
project_memory_text: str,
|
||||
domain_knowledge_text: str,
|
||||
engineering_context_text: str,
|
||||
chunks: list[ContextChunk],
|
||||
budget: int,
|
||||
) -> tuple[str, list[ContextChunk]]:
|
||||
"""Trim retrieval first, then memory, then project state until formatted context fits."""
|
||||
"""Trim retrieval -> engineering -> domain -> project memories -> identity -> state."""
|
||||
kept_chunks = list(chunks)
|
||||
formatted = _format_full_context(project_state_text, memory_text, kept_chunks)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
while len(formatted) > budget and kept_chunks:
|
||||
kept_chunks.pop()
|
||||
formatted = _format_full_context(project_state_text, memory_text, kept_chunks)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
|
||||
if len(formatted) <= budget:
|
||||
return formatted, kept_chunks
|
||||
|
||||
# Drop engineering context first.
|
||||
engineering_context_text = ""
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
if len(formatted) <= budget:
|
||||
return formatted, kept_chunks
|
||||
|
||||
# Drop domain knowledge next.
|
||||
domain_knowledge_text, _ = _truncate_text_block(domain_knowledge_text, 0)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
if len(formatted) <= budget:
|
||||
return formatted, kept_chunks
|
||||
|
||||
project_memory_text, _ = _truncate_text_block(
|
||||
project_memory_text,
|
||||
max(budget - len(project_state_text) - len(memory_text), 0),
|
||||
)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
if len(formatted) <= budget:
|
||||
return formatted, kept_chunks
|
||||
|
||||
memory_text, _ = _truncate_text_block(memory_text, max(budget - len(project_state_text), 0))
|
||||
formatted = _format_full_context(project_state_text, memory_text, kept_chunks)
|
||||
formatted = _format_full_context(
|
||||
project_state_text, memory_text, project_memory_text,
|
||||
domain_knowledge_text, engineering_context_text, kept_chunks,
|
||||
)
|
||||
if len(formatted) <= budget:
|
||||
return formatted, kept_chunks
|
||||
|
||||
project_state_text, _ = _truncate_text_block(project_state_text, budget)
|
||||
formatted = _format_full_context(project_state_text, "", [])
|
||||
formatted = _format_full_context(project_state_text, "", "", "", [])
|
||||
if len(formatted) > budget:
|
||||
formatted, _ = _truncate_text_block(formatted, budget)
|
||||
return formatted, []
|
||||
|
||||
16
src/atocore/engineering/__init__.py
Normal file
16
src/atocore/engineering/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Engineering Knowledge Layer — typed entities and relationships.
|
||||
|
||||
Layer 2 of the AtoCore architecture. Sits on top of the core machine
|
||||
layer (memories, project state, retrieval) and adds structured
|
||||
engineering objects with typed relationships so queries like "what
|
||||
requirements does this component satisfy" can be answered directly
|
||||
instead of relying on flat text search.
|
||||
|
||||
V1 entity types (from docs/architecture/engineering-ontology-v1.md):
|
||||
Component, Subsystem, Requirement, Constraint, Decision, Material,
|
||||
Parameter, Interface
|
||||
|
||||
V1 relationship types:
|
||||
CONTAINS, PART_OF, INTERFACES_WITH, SATISFIES, CONSTRAINED_BY,
|
||||
AFFECTED_BY_DECISION, ANALYZED_BY, VALIDATED_BY, DEPENDS_ON
|
||||
"""
|
||||
267
src/atocore/engineering/mirror.py
Normal file
267
src/atocore/engineering/mirror.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Human Mirror — derived readable project views from structured data.
|
||||
|
||||
Layer 3 of the AtoCore architecture. Generates human-readable markdown
|
||||
pages from the engineering entity graph, Trusted Project State, and
|
||||
active memories. These pages are DERIVED — they are not canonical
|
||||
machine truth. They are support surfaces for human inspection and
|
||||
audit comfort.
|
||||
|
||||
The mirror never invents content. Every line traces back to an entity,
|
||||
a state entry, or a memory. If the structured data is wrong, the
|
||||
mirror is wrong — fix the source, not the page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from atocore.context.project_state import get_state
|
||||
from atocore.engineering.service import (
|
||||
get_entities,
|
||||
get_relationships,
|
||||
)
|
||||
from atocore.memory.service import get_memories
|
||||
from atocore.observability.logger import get_logger
|
||||
|
||||
log = get_logger("mirror")
|
||||
|
||||
|
||||
def generate_project_overview(project: str) -> str:
|
||||
"""Generate a full project overview page in markdown."""
|
||||
sections = [
|
||||
_header(project),
|
||||
_synthesis_section(project),
|
||||
_state_section(project),
|
||||
_system_architecture(project),
|
||||
_decisions_section(project),
|
||||
_requirements_section(project),
|
||||
_materials_section(project),
|
||||
_vendors_section(project),
|
||||
_active_memories_section(project),
|
||||
_footer(project),
|
||||
]
|
||||
return "\n\n".join(s for s in sections if s)
|
||||
|
||||
|
||||
def _synthesis_section(project: str) -> str:
|
||||
"""Generate a short LLM synthesis of the current project state.
|
||||
|
||||
Reads the cached synthesis from project_state if available
|
||||
(category=status, key=synthesis_cache). If not cached, returns
|
||||
a deterministic summary from the existing structured data.
|
||||
The actual LLM-generated synthesis is produced by the weekly
|
||||
lint/synthesis pass on Dalidou (where claude CLI is available).
|
||||
"""
|
||||
entries = get_state(project)
|
||||
cached = ""
|
||||
for e in entries:
|
||||
if e.category == "status" and e.key == "synthesis_cache":
|
||||
cached = e.value
|
||||
break
|
||||
|
||||
if cached:
|
||||
return f"## Current State (auto-synthesis)\n\n> {cached}"
|
||||
|
||||
# Fallback: deterministic summary from structured data
|
||||
stage = ""
|
||||
summary = ""
|
||||
next_focus = ""
|
||||
for e in entries:
|
||||
if e.category == "status":
|
||||
if e.key == "stage":
|
||||
stage = e.value
|
||||
elif e.key == "summary":
|
||||
summary = e.value
|
||||
elif e.key == "next_focus":
|
||||
next_focus = e.value
|
||||
|
||||
if not (stage or summary or next_focus):
|
||||
return ""
|
||||
|
||||
bits = []
|
||||
if summary:
|
||||
bits.append(summary)
|
||||
if stage:
|
||||
bits.append(f"**Stage**: {stage}")
|
||||
if next_focus:
|
||||
bits.append(f"**Next**: {next_focus}")
|
||||
|
||||
return "## Current State\n\n" + "\n\n".join(bits)
|
||||
|
||||
|
||||
def _header(project: str) -> str:
|
||||
return (
|
||||
f"# {project} — Project Overview\n\n"
|
||||
f"> This page is auto-generated from AtoCore structured data.\n"
|
||||
f"> It is a **derived view**, not canonical truth. "
|
||||
f"If something is wrong here, fix the source data."
|
||||
)
|
||||
|
||||
|
||||
def _state_section(project: str) -> str:
|
||||
entries = get_state(project)
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
lines = ["## Trusted Project State"]
|
||||
by_category: dict[str, list] = {}
|
||||
for e in entries:
|
||||
by_category.setdefault(e.category.upper(), []).append(e)
|
||||
|
||||
for cat in ["DECISION", "REQUIREMENT", "STATUS", "FACT", "MILESTONE", "CONFIG", "CONTACT"]:
|
||||
items = by_category.get(cat, [])
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"\n### {cat.title()}")
|
||||
for item in items:
|
||||
value = item.value[:300]
|
||||
lines.append(f"- **{item.key}**: {value}")
|
||||
if item.source:
|
||||
lines.append(f" *(source: {item.source})*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _system_architecture(project: str) -> str:
|
||||
systems = get_entities(entity_type="system", project=project)
|
||||
subsystems = get_entities(entity_type="subsystem", project=project)
|
||||
components = get_entities(entity_type="component", project=project)
|
||||
interfaces = get_entities(entity_type="interface", project=project)
|
||||
|
||||
if not systems and not subsystems and not components:
|
||||
return ""
|
||||
|
||||
lines = ["## System Architecture"]
|
||||
|
||||
for system in systems:
|
||||
lines.append(f"\n### {system.name}")
|
||||
if system.description:
|
||||
lines.append(f"{system.description}")
|
||||
|
||||
rels = get_relationships(system.id, direction="outgoing")
|
||||
children = []
|
||||
for rel in rels:
|
||||
if rel.relationship_type == "contains":
|
||||
child = next(
|
||||
(s for s in subsystems + components if s.id == rel.target_entity_id),
|
||||
None,
|
||||
)
|
||||
if child:
|
||||
children.append(child)
|
||||
|
||||
if children:
|
||||
lines.append("\n**Contains:**")
|
||||
for child in children:
|
||||
desc = f" — {child.description}" if child.description else ""
|
||||
lines.append(f"- [{child.entity_type}] **{child.name}**{desc}")
|
||||
|
||||
child_rels = get_relationships(child.id, direction="both")
|
||||
for cr in child_rels:
|
||||
if cr.relationship_type in ("uses_material", "interfaces_with", "constrained_by"):
|
||||
other_id = (
|
||||
cr.target_entity_id
|
||||
if cr.source_entity_id == child.id
|
||||
else cr.source_entity_id
|
||||
)
|
||||
other = next(
|
||||
(e for e in get_entities(project=project, limit=200)
|
||||
if e.id == other_id),
|
||||
None,
|
||||
)
|
||||
if other:
|
||||
lines.append(
|
||||
f" - *{cr.relationship_type}* → "
|
||||
f"[{other.entity_type}] {other.name}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _decisions_section(project: str) -> str:
|
||||
decisions = get_entities(entity_type="decision", project=project)
|
||||
if not decisions:
|
||||
return ""
|
||||
|
||||
lines = ["## Decisions"]
|
||||
for d in decisions:
|
||||
lines.append(f"\n### {d.name}")
|
||||
if d.description:
|
||||
lines.append(d.description)
|
||||
rels = get_relationships(d.id, direction="outgoing")
|
||||
for rel in rels:
|
||||
if rel.relationship_type == "affected_by_decision":
|
||||
affected = next(
|
||||
(e for e in get_entities(project=project, limit=200)
|
||||
if e.id == rel.target_entity_id),
|
||||
None,
|
||||
)
|
||||
if affected:
|
||||
lines.append(
|
||||
f"- Affects: [{affected.entity_type}] {affected.name}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _requirements_section(project: str) -> str:
|
||||
reqs = get_entities(entity_type="requirement", project=project)
|
||||
constraints = get_entities(entity_type="constraint", project=project)
|
||||
if not reqs and not constraints:
|
||||
return ""
|
||||
|
||||
lines = ["## Requirements & Constraints"]
|
||||
for r in reqs:
|
||||
lines.append(f"- **{r.name}**: {r.description}" if r.description else f"- **{r.name}**")
|
||||
for c in constraints:
|
||||
lines.append(f"- [constraint] **{c.name}**: {c.description}" if c.description else f"- [constraint] **{c.name}**")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _materials_section(project: str) -> str:
|
||||
materials = get_entities(entity_type="material", project=project)
|
||||
if not materials:
|
||||
return ""
|
||||
|
||||
lines = ["## Materials"]
|
||||
for m in materials:
|
||||
desc = f" — {m.description}" if m.description else ""
|
||||
lines.append(f"- **{m.name}**{desc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _vendors_section(project: str) -> str:
|
||||
vendors = get_entities(entity_type="vendor", project=project)
|
||||
if not vendors:
|
||||
return ""
|
||||
|
||||
lines = ["## Vendors"]
|
||||
for v in vendors:
|
||||
desc = f" — {v.description}" if v.description else ""
|
||||
lines.append(f"- **{v.name}**{desc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _active_memories_section(project: str) -> str:
|
||||
memories = get_memories(project=project, active_only=True, limit=20)
|
||||
if not memories:
|
||||
return ""
|
||||
|
||||
lines = ["## Active Memories"]
|
||||
for m in memories:
|
||||
conf = f" (conf: {m.confidence:.2f})" if m.confidence < 1.0 else ""
|
||||
refs = f" | refs: {m.reference_count}" if m.reference_count > 0 else ""
|
||||
lines.append(f"- [{m.memory_type}]{conf}{refs} {m.content[:200]}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _footer(project: str) -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
return (
|
||||
f"---\n\n"
|
||||
f"*Generated by AtoCore Human Mirror at {now}. "
|
||||
f"This is a derived view — not canonical truth.*"
|
||||
)
|
||||
317
src/atocore/engineering/service.py
Normal file
317
src/atocore/engineering/service.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Engineering entity and relationship CRUD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from atocore.models.database import get_connection
|
||||
from atocore.observability.logger import get_logger
|
||||
|
||||
log = get_logger("engineering")
|
||||
|
||||
ENTITY_TYPES = [
|
||||
"project",
|
||||
"system",
|
||||
"subsystem",
|
||||
"component",
|
||||
"interface",
|
||||
"requirement",
|
||||
"constraint",
|
||||
"decision",
|
||||
"material",
|
||||
"parameter",
|
||||
"analysis_model",
|
||||
"result",
|
||||
"validation_claim",
|
||||
"vendor",
|
||||
"process",
|
||||
]
|
||||
|
||||
RELATIONSHIP_TYPES = [
|
||||
"contains",
|
||||
"part_of",
|
||||
"interfaces_with",
|
||||
"satisfies",
|
||||
"constrained_by",
|
||||
"affected_by_decision",
|
||||
"analyzed_by",
|
||||
"validated_by",
|
||||
"depends_on",
|
||||
"uses_material",
|
||||
"described_by",
|
||||
"supersedes",
|
||||
]
|
||||
|
||||
ENTITY_STATUSES = ["candidate", "active", "superseded", "invalid"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entity:
|
||||
id: str
|
||||
entity_type: str
|
||||
name: str
|
||||
project: str
|
||||
description: str = ""
|
||||
properties: dict = field(default_factory=dict)
|
||||
status: str = "active"
|
||||
confidence: float = 1.0
|
||||
source_refs: list[str] = field(default_factory=list)
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Relationship:
|
||||
id: str
|
||||
source_entity_id: str
|
||||
target_entity_id: str
|
||||
relationship_type: str
|
||||
confidence: float = 1.0
|
||||
source_refs: list[str] = field(default_factory=list)
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
def init_engineering_schema() -> None:
|
||||
with get_connection() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
entity_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
properties TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
source_refs TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS relationships (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_entity_id TEXT NOT NULL,
|
||||
target_entity_id TEXT NOT NULL,
|
||||
relationship_type TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
source_refs TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (source_entity_id) REFERENCES entities(id),
|
||||
FOREIGN KEY (target_entity_id) REFERENCES entities(id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_project
|
||||
ON entities(project)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_type
|
||||
ON entities(entity_type)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_relationships_source
|
||||
ON relationships(source_entity_id)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_relationships_target
|
||||
ON relationships(target_entity_id)
|
||||
""")
|
||||
log.info("engineering_schema_initialized")
|
||||
|
||||
|
||||
def create_entity(
|
||||
entity_type: str,
|
||||
name: str,
|
||||
project: str = "",
|
||||
description: str = "",
|
||||
properties: dict | None = None,
|
||||
status: str = "active",
|
||||
confidence: float = 1.0,
|
||||
source_refs: list[str] | None = None,
|
||||
) -> Entity:
|
||||
if entity_type not in ENTITY_TYPES:
|
||||
raise ValueError(f"Invalid entity type: {entity_type}. Must be one of {ENTITY_TYPES}")
|
||||
if status not in ENTITY_STATUSES:
|
||||
raise ValueError(f"Invalid status: {status}. Must be one of {ENTITY_STATUSES}")
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Entity name must be non-empty")
|
||||
|
||||
entity_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
props = properties or {}
|
||||
refs = source_refs or []
|
||||
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO entities
|
||||
(id, entity_type, name, project, description, properties,
|
||||
status, confidence, source_refs, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
entity_id, entity_type, name.strip(), project,
|
||||
description, json.dumps(props), status, confidence,
|
||||
json.dumps(refs), now, now,
|
||||
),
|
||||
)
|
||||
|
||||
log.info("entity_created", entity_id=entity_id, entity_type=entity_type, name=name)
|
||||
return Entity(
|
||||
id=entity_id, entity_type=entity_type, name=name.strip(),
|
||||
project=project, description=description, properties=props,
|
||||
status=status, confidence=confidence, source_refs=refs,
|
||||
created_at=now, updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def create_relationship(
|
||||
source_entity_id: str,
|
||||
target_entity_id: str,
|
||||
relationship_type: str,
|
||||
confidence: float = 1.0,
|
||||
source_refs: list[str] | None = None,
|
||||
) -> Relationship:
|
||||
if relationship_type not in RELATIONSHIP_TYPES:
|
||||
raise ValueError(f"Invalid relationship type: {relationship_type}")
|
||||
|
||||
rel_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
refs = source_refs or []
|
||||
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO relationships
|
||||
(id, source_entity_id, target_entity_id, relationship_type,
|
||||
confidence, source_refs, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(rel_id, source_entity_id, target_entity_id,
|
||||
relationship_type, confidence, json.dumps(refs), now),
|
||||
)
|
||||
|
||||
log.info(
|
||||
"relationship_created",
|
||||
rel_id=rel_id,
|
||||
source=source_entity_id,
|
||||
target=target_entity_id,
|
||||
rel_type=relationship_type,
|
||||
)
|
||||
return Relationship(
|
||||
id=rel_id, source_entity_id=source_entity_id,
|
||||
target_entity_id=target_entity_id,
|
||||
relationship_type=relationship_type,
|
||||
confidence=confidence, source_refs=refs, created_at=now,
|
||||
)
|
||||
|
||||
|
||||
def get_entities(
|
||||
entity_type: str | None = None,
|
||||
project: str | None = None,
|
||||
status: str = "active",
|
||||
name_contains: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[Entity]:
|
||||
query = "SELECT * FROM entities WHERE status = ?"
|
||||
params: list = [status]
|
||||
|
||||
if entity_type:
|
||||
query += " AND entity_type = ?"
|
||||
params.append(entity_type)
|
||||
if project is not None:
|
||||
query += " AND project = ?"
|
||||
params.append(project)
|
||||
if name_contains:
|
||||
query += " AND name LIKE ?"
|
||||
params.append(f"%{name_contains}%")
|
||||
|
||||
query += " ORDER BY entity_type, name LIMIT ?"
|
||||
params.append(min(limit, 500))
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [_row_to_entity(r) for r in rows]
|
||||
|
||||
|
||||
def get_entity(entity_id: str) -> Entity | None:
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM entities WHERE id = ?", (entity_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_entity(row)
|
||||
|
||||
|
||||
def get_relationships(
|
||||
entity_id: str,
|
||||
direction: str = "both",
|
||||
) -> list[Relationship]:
|
||||
results = []
|
||||
with get_connection() as conn:
|
||||
if direction in ("outgoing", "both"):
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM relationships WHERE source_entity_id = ?",
|
||||
(entity_id,),
|
||||
).fetchall()
|
||||
results.extend(_row_to_relationship(r) for r in rows)
|
||||
if direction in ("incoming", "both"):
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM relationships WHERE target_entity_id = ?",
|
||||
(entity_id,),
|
||||
).fetchall()
|
||||
results.extend(_row_to_relationship(r) for r in rows)
|
||||
return results
|
||||
|
||||
|
||||
def get_entity_with_context(entity_id: str) -> dict | None:
|
||||
entity = get_entity(entity_id)
|
||||
if entity is None:
|
||||
return None
|
||||
relationships = get_relationships(entity_id)
|
||||
related_ids = set()
|
||||
for rel in relationships:
|
||||
related_ids.add(rel.source_entity_id)
|
||||
related_ids.add(rel.target_entity_id)
|
||||
related_ids.discard(entity_id)
|
||||
|
||||
related_entities = {}
|
||||
for rid in related_ids:
|
||||
e = get_entity(rid)
|
||||
if e:
|
||||
related_entities[rid] = e
|
||||
|
||||
return {
|
||||
"entity": entity,
|
||||
"relationships": relationships,
|
||||
"related_entities": related_entities,
|
||||
}
|
||||
|
||||
|
||||
def _row_to_entity(row) -> Entity:
|
||||
return Entity(
|
||||
id=row["id"],
|
||||
entity_type=row["entity_type"],
|
||||
name=row["name"],
|
||||
project=row["project"] or "",
|
||||
description=row["description"] or "",
|
||||
properties=json.loads(row["properties"] or "{}"),
|
||||
status=row["status"],
|
||||
confidence=row["confidence"],
|
||||
source_refs=json.loads(row["source_refs"] or "[]"),
|
||||
created_at=row["created_at"] or "",
|
||||
updated_at=row["updated_at"] or "",
|
||||
)
|
||||
|
||||
|
||||
def _row_to_relationship(row) -> Relationship:
|
||||
return Relationship(
|
||||
id=row["id"],
|
||||
source_entity_id=row["source_entity_id"],
|
||||
target_entity_id=row["target_entity_id"],
|
||||
relationship_type=row["relationship_type"],
|
||||
confidence=row["confidence"],
|
||||
source_refs=json.loads(row["source_refs"] or "[]"),
|
||||
created_at=row["created_at"] or "",
|
||||
)
|
||||
298
src/atocore/engineering/wiki.py
Normal file
298
src/atocore/engineering/wiki.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""AtoCore Wiki — navigable HTML pages from structured data.
|
||||
|
||||
A lightweight wiki served directly from the AtoCore API. Every page is
|
||||
generated on-demand from the database so it's always current. Source of
|
||||
truth is the database — the wiki is a derived view.
|
||||
|
||||
Routes:
|
||||
/wiki Homepage with project list + search
|
||||
/wiki/projects/{name} Full project overview
|
||||
/wiki/entities/{id} Entity detail with relationships
|
||||
/wiki/search?q=... Search entities, memories, state
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import markdown as md
|
||||
|
||||
from atocore.context.project_state import get_state
|
||||
from atocore.engineering.service import (
|
||||
get_entities,
|
||||
get_entity,
|
||||
get_entity_with_context,
|
||||
get_relationships,
|
||||
)
|
||||
from atocore.memory.service import get_memories
|
||||
from atocore.projects.registry import load_project_registry
|
||||
|
||||
|
||||
def render_html(title: str, body_html: str, breadcrumbs: list[tuple[str, str]] | None = None) -> str:
|
||||
nav = ""
|
||||
if breadcrumbs:
|
||||
parts = []
|
||||
for label, href in breadcrumbs:
|
||||
if href:
|
||||
parts.append(f'<a href="{href}">{label}</a>')
|
||||
else:
|
||||
parts.append(f"<span>{label}</span>")
|
||||
nav = f'<nav class="breadcrumbs">{" / ".join(parts)}</nav>'
|
||||
|
||||
return _TEMPLATE.replace("{{title}}", title).replace("{{nav}}", nav).replace("{{body}}", body_html)
|
||||
|
||||
|
||||
def render_homepage() -> str:
|
||||
projects = []
|
||||
try:
|
||||
registered = load_project_registry()
|
||||
for p in registered:
|
||||
entity_count = len(get_entities(project=p.project_id, limit=200))
|
||||
memory_count = len(get_memories(project=p.project_id, active_only=True, limit=200))
|
||||
state_entries = get_state(p.project_id)
|
||||
|
||||
# Pull stage/type/client from state entries
|
||||
stage = ""
|
||||
proj_type = ""
|
||||
client = ""
|
||||
for e in state_entries:
|
||||
if e.category == "status":
|
||||
if e.key == "stage":
|
||||
stage = e.value
|
||||
elif e.key == "type":
|
||||
proj_type = e.value
|
||||
elif e.key == "client":
|
||||
client = e.value
|
||||
|
||||
projects.append({
|
||||
"id": p.project_id,
|
||||
"description": p.description,
|
||||
"entities": entity_count,
|
||||
"memories": memory_count,
|
||||
"state": len(state_entries),
|
||||
"stage": stage,
|
||||
"type": proj_type,
|
||||
"client": client,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Group by high-level bucket
|
||||
buckets: dict[str, list] = {
|
||||
"Active Contracts": [],
|
||||
"Leads & Prospects": [],
|
||||
"Internal Tools & Infra": [],
|
||||
"Other": [],
|
||||
}
|
||||
for p in projects:
|
||||
t = p["type"].lower()
|
||||
s = p["stage"].lower()
|
||||
if "lead" in t or "lead" in s or "prospect" in s:
|
||||
buckets["Leads & Prospects"].append(p)
|
||||
elif "contract" in t or ("active" in s and "contract" in s):
|
||||
buckets["Active Contracts"].append(p)
|
||||
elif "infra" in t or "tool" in t or "internal" in t:
|
||||
buckets["Internal Tools & Infra"].append(p)
|
||||
else:
|
||||
buckets["Other"].append(p)
|
||||
|
||||
lines = ['<h1>AtoCore Wiki</h1>']
|
||||
lines.append('<form class="search-box" action="/wiki/search" method="get">')
|
||||
lines.append('<input type="text" name="q" placeholder="Search entities, memories, projects..." autofocus>')
|
||||
lines.append('<button type="submit">Search</button>')
|
||||
lines.append('</form>')
|
||||
|
||||
for bucket_name, items in buckets.items():
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f'<h2>{bucket_name}</h2>')
|
||||
lines.append('<div class="card-grid">')
|
||||
for p in items:
|
||||
client_line = f'<div class="client">{p["client"]}</div>' if p["client"] else ''
|
||||
stage_tag = f'<span class="tag">{p["stage"].split(" — ")[0]}</span>' if p["stage"] else ''
|
||||
lines.append(f'<a href="/wiki/projects/{p["id"]}" class="card">')
|
||||
lines.append(f'<h3>{p["id"]} {stage_tag}</h3>')
|
||||
lines.append(client_line)
|
||||
lines.append(f'<p>{p["description"][:140]}</p>')
|
||||
lines.append(f'<div class="stats">{p["entities"]} entities · {p["memories"]} memories · {p["state"]} state</div>')
|
||||
lines.append('</a>')
|
||||
lines.append('</div>')
|
||||
|
||||
# Quick stats
|
||||
all_entities = get_entities(limit=500)
|
||||
all_memories = get_memories(active_only=True, limit=500)
|
||||
lines.append('<h2>System</h2>')
|
||||
lines.append(f'<p>{len(all_entities)} entities · {len(all_memories)} active memories · {len(projects)} projects</p>')
|
||||
lines.append(f'<p><a href="/admin/dashboard">API Dashboard (JSON)</a> · <a href="/health">Health Check</a></p>')
|
||||
|
||||
return render_html("AtoCore Wiki", "\n".join(lines))
|
||||
|
||||
|
||||
def render_project(project: str) -> str:
|
||||
from atocore.engineering.mirror import generate_project_overview
|
||||
|
||||
markdown_content = generate_project_overview(project)
|
||||
# Convert entity names to links
|
||||
entities = get_entities(project=project, limit=200)
|
||||
html_body = md.markdown(markdown_content, extensions=["tables", "fenced_code"])
|
||||
|
||||
for ent in sorted(entities, key=lambda e: len(e.name), reverse=True):
|
||||
linked = f'<a href="/wiki/entities/{ent.id}" title="{ent.entity_type}">{ent.name}</a>'
|
||||
html_body = html_body.replace(f"<strong>{ent.name}</strong>", f"<strong>{linked}</strong>", 1)
|
||||
|
||||
return render_html(
|
||||
f"{project}",
|
||||
html_body,
|
||||
breadcrumbs=[("Wiki", "/wiki"), (project, "")],
|
||||
)
|
||||
|
||||
|
||||
def render_entity(entity_id: str) -> str | None:
|
||||
ctx = get_entity_with_context(entity_id)
|
||||
if ctx is None:
|
||||
return None
|
||||
|
||||
ent = ctx["entity"]
|
||||
lines = [f'<h1>[{ent.entity_type}] {ent.name}</h1>']
|
||||
|
||||
if ent.project:
|
||||
lines.append(f'<p>Project: <a href="/wiki/projects/{ent.project}">{ent.project}</a></p>')
|
||||
if ent.description:
|
||||
lines.append(f'<p>{ent.description}</p>')
|
||||
if ent.properties:
|
||||
lines.append('<h2>Properties</h2><ul>')
|
||||
for k, v in ent.properties.items():
|
||||
lines.append(f'<li><strong>{k}</strong>: {v}</li>')
|
||||
lines.append('</ul>')
|
||||
|
||||
lines.append(f'<p class="meta">confidence: {ent.confidence} · status: {ent.status} · created: {ent.created_at}</p>')
|
||||
|
||||
if ctx["relationships"]:
|
||||
lines.append('<h2>Relationships</h2><ul>')
|
||||
for rel in ctx["relationships"]:
|
||||
other_id = rel.target_entity_id if rel.source_entity_id == entity_id else rel.source_entity_id
|
||||
other = ctx["related_entities"].get(other_id)
|
||||
if other:
|
||||
direction = "\u2192" if rel.source_entity_id == entity_id else "\u2190"
|
||||
lines.append(
|
||||
f'<li>{direction} <em>{rel.relationship_type}</em> '
|
||||
f'<a href="/wiki/entities/{other_id}">[{other.entity_type}] {other.name}</a></li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
breadcrumbs = [("Wiki", "/wiki")]
|
||||
if ent.project:
|
||||
breadcrumbs.append((ent.project, f"/wiki/projects/{ent.project}"))
|
||||
breadcrumbs.append((ent.name, ""))
|
||||
|
||||
return render_html(ent.name, "\n".join(lines), breadcrumbs=breadcrumbs)
|
||||
|
||||
|
||||
def render_search(query: str) -> str:
|
||||
lines = [f'<h1>Search: "{query}"</h1>']
|
||||
|
||||
# Search entities by name
|
||||
entities = get_entities(name_contains=query, limit=20)
|
||||
if entities:
|
||||
lines.append(f'<h2>Entities ({len(entities)})</h2><ul>')
|
||||
for e in entities:
|
||||
proj = f' <span class="tag">{e.project}</span>' if e.project else ''
|
||||
lines.append(
|
||||
f'<li><a href="/wiki/entities/{e.id}">[{e.entity_type}] {e.name}</a>{proj}'
|
||||
f'{" — " + e.description[:100] if e.description else ""}</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
# Search memories
|
||||
all_memories = get_memories(active_only=True, limit=200)
|
||||
query_lower = query.lower()
|
||||
matching_mems = [m for m in all_memories if query_lower in m.content.lower()][:10]
|
||||
if matching_mems:
|
||||
lines.append(f'<h2>Memories ({len(matching_mems)})</h2><ul>')
|
||||
for m in matching_mems:
|
||||
proj = f' <span class="tag">{m.project}</span>' if m.project else ''
|
||||
lines.append(f'<li>[{m.memory_type}]{proj} {m.content[:200]}</li>')
|
||||
lines.append('</ul>')
|
||||
|
||||
if not entities and not matching_mems:
|
||||
lines.append('<p>No results found.</p>')
|
||||
|
||||
lines.append('<form class="search-box" action="/wiki/search" method="get">')
|
||||
lines.append(f'<input type="text" name="q" value="{query}" autofocus>')
|
||||
lines.append('<button type="submit">Search</button>')
|
||||
lines.append('</form>')
|
||||
|
||||
return render_html(
|
||||
f"Search: {query}",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Search", "")],
|
||||
)
|
||||
|
||||
|
||||
_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{title}} — AtoCore</title>
|
||||
<style>
|
||||
:root { --bg: #fafafa; --text: #1a1a2e; --accent: #2563eb; --border: #e2e8f0; --card: #fff; --hover: #f1f5f9; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg: #0f172a; --text: #e2e8f0; --accent: #60a5fa; --border: #334155; --card: #1e293b; --hover: #334155; }
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.7; color: var(--text); background: var(--bg);
|
||||
max-width: 800px; margin: 0 auto; padding: 1.5rem;
|
||||
}
|
||||
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: var(--accent); }
|
||||
h2 { font-size: 1.3rem; margin-top: 2rem; margin-bottom: 0.6rem; padding-bottom: 0.2rem; border-bottom: 2px solid var(--border); }
|
||||
h3 { font-size: 1.1rem; margin-top: 1.2rem; margin-bottom: 0.4rem; }
|
||||
p { margin-bottom: 0.8rem; }
|
||||
ul { margin-left: 1.5rem; margin-bottom: 1rem; }
|
||||
li { margin-bottom: 0.3rem; }
|
||||
li ul { margin-top: 0.2rem; }
|
||||
strong { color: var(--accent); font-weight: 600; }
|
||||
em { opacity: 0.7; font-size: 0.9em; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
blockquote {
|
||||
background: var(--card); border-left: 4px solid var(--accent);
|
||||
padding: 0.6rem 1rem; margin: 1rem 0; border-radius: 0 6px 6px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
|
||||
.breadcrumbs { margin-bottom: 1.5rem; font-size: 0.85em; opacity: 0.7; }
|
||||
.breadcrumbs a { opacity: 0.8; }
|
||||
.meta { font-size: 0.8em; opacity: 0.5; margin-top: 0.5rem; }
|
||||
.tag { background: var(--accent); color: var(--bg); padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.75em; margin-left: 0.3rem; }
|
||||
.search-box { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
|
||||
.search-box input {
|
||||
flex: 1; padding: 0.6rem 1rem; border: 2px solid var(--border);
|
||||
border-radius: 8px; background: var(--card); color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.search-box input:focus { border-color: var(--accent); outline: none; }
|
||||
.search-box button {
|
||||
padding: 0.6rem 1.2rem; background: var(--accent); color: var(--bg);
|
||||
border: none; border-radius: 8px; cursor: pointer; font-size: 1rem;
|
||||
}
|
||||
.card-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; margin: 1rem 0; }
|
||||
@media (min-width: 600px) { .card-grid { grid-template-columns: 1fr 1fr; } }
|
||||
.card {
|
||||
display: block; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 1.2rem; text-decoration: none;
|
||||
color: var(--text); transition: border-color 0.2s;
|
||||
}
|
||||
.card:hover { border-color: var(--accent); background: var(--hover); text-decoration: none; }
|
||||
.card h3 { color: var(--accent); margin: 0 0 0.3rem 0; }
|
||||
.card p { font-size: 0.9em; margin: 0; opacity: 0.8; }
|
||||
.card .stats { font-size: 0.8em; margin-top: 0.5rem; opacity: 0.5; }
|
||||
.card .client { font-size: 0.85em; opacity: 0.65; margin-bottom: 0.3rem; font-style: italic; }
|
||||
.card h3 .tag { font-size: 0.65em; vertical-align: middle; margin-left: 0.4rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{nav}}
|
||||
{{body}}
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -63,6 +63,7 @@ def record_interaction(
|
||||
chunks_used: list[str] | None = None,
|
||||
context_pack: dict | None = None,
|
||||
reinforce: bool = True,
|
||||
extract: bool = False,
|
||||
) -> Interaction:
|
||||
"""Persist a single interaction to the audit trail.
|
||||
|
||||
@@ -163,6 +164,30 @@ def record_interaction(
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
if extract and (response or response_summary):
|
||||
try:
|
||||
from atocore.memory.extractor import extract_candidates_from_interaction
|
||||
from atocore.memory.service import create_memory
|
||||
|
||||
candidates = extract_candidates_from_interaction(interaction)
|
||||
for candidate in candidates:
|
||||
try:
|
||||
create_memory(
|
||||
memory_type=candidate.memory_type,
|
||||
content=candidate.content,
|
||||
project=candidate.project,
|
||||
confidence=candidate.confidence,
|
||||
status="candidate",
|
||||
)
|
||||
except ValueError:
|
||||
pass # duplicate or validation error — skip silently
|
||||
except Exception as exc: # pragma: no cover - extraction must never block capture
|
||||
log.error(
|
||||
"extraction_failed_on_capture",
|
||||
interaction_id=interaction_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
return interaction
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from atocore import __version__
|
||||
from atocore.api.routes import router
|
||||
import atocore.config as _config
|
||||
from atocore.context.project_state import init_project_state_schema
|
||||
from atocore.engineering.service import init_engineering_schema
|
||||
from atocore.ingestion.pipeline import get_source_status
|
||||
from atocore.models.database import init_db
|
||||
from atocore.observability.logger import get_logger, setup_logging
|
||||
@@ -29,6 +30,7 @@ async def lifespan(app: FastAPI):
|
||||
_config.ensure_runtime_dirs()
|
||||
init_db()
|
||||
init_project_state_schema()
|
||||
init_engineering_schema()
|
||||
log.info(
|
||||
"startup_ready",
|
||||
env=_config.settings.env,
|
||||
|
||||
373
src/atocore/memory/extractor_llm.py
Normal file
373
src/atocore/memory/extractor_llm.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""LLM-assisted candidate-memory extraction via the Claude Code CLI.
|
||||
|
||||
Day 4 of the 2026-04-11 mini-phase: the rule-based extractor hit 0%
|
||||
recall against real conversational claude-code captures (Day 2 baseline
|
||||
scorecard in ``scripts/eval_data/extractor_labels_2026-04-11.json``),
|
||||
with false negatives spread across 5 distinct miss classes. A single
|
||||
rule expansion cannot close that gap, so this module adds an optional
|
||||
LLM-assisted mode that shells out to the ``claude -p`` (Claude Code
|
||||
non-interactive) CLI with a focused extraction system prompt. That
|
||||
path reuses the user's existing Claude.ai OAuth credentials — no API
|
||||
key anywhere, per the 2026-04-11 decision.
|
||||
|
||||
Trust rules carried forward from the rule-based extractor:
|
||||
|
||||
- Candidates are NEVER auto-promoted. Caller persists with
|
||||
``status="candidate"`` and a human reviews via the triage CLI.
|
||||
- This path is additive. The rule-based extractor keeps working
|
||||
exactly as before; callers opt in by importing this module.
|
||||
- Extraction stays off the capture hot path — this is batch / manual
|
||||
only, per the 2026-04-11 decision.
|
||||
- Failure is silent. Missing CLI, non-zero exit, malformed JSON,
|
||||
timeout — all return an empty list and log an error. Never raises
|
||||
into the caller; the capture audit trail must not break on an
|
||||
optional side effect.
|
||||
|
||||
Configuration:
|
||||
|
||||
- Requires the ``claude`` CLI on PATH (``claude --version`` should work).
|
||||
- ``ATOCORE_LLM_EXTRACTOR_MODEL`` overrides the model alias (default
|
||||
``sonnet``).
|
||||
- ``ATOCORE_LLM_EXTRACTOR_TIMEOUT_S`` overrides the per-call timeout
|
||||
(default 90 seconds — first invocation is slow because Node.js
|
||||
startup plus OAuth check is non-trivial).
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- We run ``claude -p`` with ``--model <alias>``,
|
||||
``--append-system-prompt`` for the extraction instructions,
|
||||
``--no-session-persistence`` so we don't pollute session history,
|
||||
and ``--disable-slash-commands`` so stray ``/foo`` in an extracted
|
||||
response never triggers something.
|
||||
- The CLI is invoked from a temp working directory so it does not
|
||||
auto-discover ``CLAUDE.md`` / ``DEV-LEDGER.md`` / ``AGENTS.md``
|
||||
from the repo root. We want a bare extraction context, not the
|
||||
full project briefing. We can't use ``--bare`` because that
|
||||
forces API-key auth; the temp-cwd trick is the lightest way to
|
||||
keep OAuth auth while skipping project context loading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
from atocore.interactions.service import Interaction
|
||||
from atocore.memory.extractor import MemoryCandidate
|
||||
from atocore.memory.service import MEMORY_TYPES
|
||||
from atocore.observability.logger import get_logger
|
||||
|
||||
log = get_logger("extractor_llm")
|
||||
|
||||
LLM_EXTRACTOR_VERSION = "llm-0.4.0"
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_LLM_EXTRACTOR_MODEL", "sonnet")
|
||||
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_LLM_EXTRACTOR_TIMEOUT_S", "90"))
|
||||
MAX_RESPONSE_CHARS = 8000
|
||||
MAX_PROMPT_CHARS = 2000
|
||||
|
||||
_SYSTEM_PROMPT = """You extract memory candidates from LLM conversation turns for a personal context engine called AtoCore.
|
||||
|
||||
AtoCore is the brain for Atomaste's engineering work. Known projects:
|
||||
p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, atocore,
|
||||
abb-space. Unknown project names — still tag them, the system auto-detects.
|
||||
|
||||
Your job is to emit SIGNALS that matter for future context. Be aggressive:
|
||||
err on the side of capturing useful signal. Triage filters noise downstream.
|
||||
|
||||
WHAT TO EMIT (in order of importance):
|
||||
|
||||
1. PROJECT ACTIVITY — any mention of a project with context worth remembering:
|
||||
- "Schott quote received for ABB-Space" (event + project)
|
||||
- "Cédric asked about p06 firmware timing" (stakeholder event)
|
||||
- "Still waiting on Zygo lead-time from Nabeel" (blocker status)
|
||||
- "p05 vendor decision needs to happen this week" (action item)
|
||||
|
||||
2. DECISIONS AND CHOICES — anything that commits to a direction:
|
||||
- "Going with Zygo Verifire SV for p05" (decision)
|
||||
- "Dropping stitching from primary workflow" (design choice)
|
||||
- "USB SSD mandatory, not SD card" (architectural commitment)
|
||||
|
||||
3. DURABLE ENGINEERING INSIGHT — earned knowledge that generalizes:
|
||||
- "CTE gradient dominates WFE at F/1.2" (materials insight)
|
||||
- "Preston model breaks below 5N because contact assumption fails"
|
||||
- "m=1 coma NOT correctable by force modulation" (controls insight)
|
||||
Test: would a competent engineer NEED experience to know this?
|
||||
If it's textbook/google-findable, skip it.
|
||||
|
||||
4. STAKEHOLDER AND VENDOR EVENTS:
|
||||
- "Email sent to Nabeel 2026-04-13 asking for lead time"
|
||||
- "Meeting with Jason on Table 7 next Tuesday"
|
||||
- "Starspec wants updated CAD by Friday"
|
||||
|
||||
5. PREFERENCES AND ADAPTATIONS that shape how Antoine works:
|
||||
- "Antoine prefers OAuth over API keys"
|
||||
- "Extraction stays off the capture hot path"
|
||||
|
||||
WHAT TO SKIP:
|
||||
|
||||
- Pure conversational filler ("ok thanks", "let me check")
|
||||
- Instructional help content ("run this command", "here's how to...")
|
||||
- Obvious textbook facts anyone can google in 30 seconds
|
||||
- Session meta-chatter ("let me commit this", "deploy running")
|
||||
- Transient system state snapshots ("36 active memories right now")
|
||||
|
||||
CANDIDATE TYPES — choose the best fit:
|
||||
|
||||
- project — a fact, decision, or event specific to one named project
|
||||
- knowledge — durable engineering insight (use domain, not project)
|
||||
- preference — how Antoine works / wants things done
|
||||
- adaptation — a standing rule or adjustment to behavior
|
||||
- episodic — a stakeholder event or milestone worth remembering
|
||||
|
||||
DOMAINS for knowledge candidates (required when type=knowledge and project is empty):
|
||||
physics, materials, optics, mechanics, manufacturing, metrology,
|
||||
controls, software, math, finance, business
|
||||
|
||||
TRUST HIERARCHY:
|
||||
|
||||
- project-specific: set project to the project id, leave domain empty
|
||||
- domain knowledge: set domain, leave project empty
|
||||
- events/activity: use project, type=project or episodic
|
||||
- one conversation can produce MULTIPLE candidates — emit them all
|
||||
|
||||
OUTPUT RULES:
|
||||
|
||||
- Each candidate content under 250 characters, stands alone
|
||||
- Default confidence 0.5. Raise to 0.7 only for ratified/committed claims.
|
||||
- Raw JSON array, no prose, no markdown fences
|
||||
- Empty array [] is fine when the conversation has no durable signal
|
||||
|
||||
Each element:
|
||||
{"type": "project|knowledge|preference|adaptation|episodic", "content": "...", "project": "...", "domain": "", "confidence": 0.5}"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMExtractionResult:
|
||||
candidates: list[MemoryCandidate]
|
||||
raw_output: str
|
||||
error: str = ""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _sandbox_cwd() -> str:
|
||||
"""Return a stable temp directory for ``claude -p`` invocations.
|
||||
|
||||
We want the CLI to run from a directory that does NOT contain
|
||||
``CLAUDE.md`` / ``DEV-LEDGER.md`` / ``AGENTS.md``, so every
|
||||
extraction call starts with a clean context instead of the full
|
||||
AtoCore project briefing. Cached so the directory persists for
|
||||
the lifetime of the process.
|
||||
"""
|
||||
return tempfile.mkdtemp(prefix="ato-llm-extract-")
|
||||
|
||||
|
||||
def _cli_available() -> bool:
|
||||
return shutil.which("claude") is not None
|
||||
|
||||
|
||||
def extract_candidates_llm(
|
||||
interaction: Interaction,
|
||||
model: str | None = None,
|
||||
timeout_s: float | None = None,
|
||||
) -> list[MemoryCandidate]:
|
||||
"""Run the LLM-assisted extractor against one interaction.
|
||||
|
||||
Returns a list of ``MemoryCandidate`` objects, empty on any
|
||||
failure path. The caller is responsible for persistence.
|
||||
"""
|
||||
return extract_candidates_llm_verbose(
|
||||
interaction,
|
||||
model=model,
|
||||
timeout_s=timeout_s,
|
||||
).candidates
|
||||
|
||||
|
||||
def extract_candidates_llm_verbose(
|
||||
interaction: Interaction,
|
||||
model: str | None = None,
|
||||
timeout_s: float | None = None,
|
||||
) -> LLMExtractionResult:
|
||||
"""Like ``extract_candidates_llm`` but also returns the raw
|
||||
subprocess output and any error encountered, for eval / debugging.
|
||||
"""
|
||||
if not _cli_available():
|
||||
return LLMExtractionResult(
|
||||
candidates=[],
|
||||
raw_output="",
|
||||
error="claude_cli_missing",
|
||||
)
|
||||
|
||||
response_text = (interaction.response or "").strip()
|
||||
if not response_text:
|
||||
return LLMExtractionResult(candidates=[], raw_output="", error="empty_response")
|
||||
|
||||
prompt_excerpt = (interaction.prompt or "")[:MAX_PROMPT_CHARS]
|
||||
response_excerpt = response_text[:MAX_RESPONSE_CHARS]
|
||||
user_message = (
|
||||
f"PROJECT HINT (may be empty): {interaction.project or ''}\n\n"
|
||||
f"USER PROMPT:\n{prompt_excerpt}\n\n"
|
||||
f"ASSISTANT RESPONSE:\n{response_excerpt}\n\n"
|
||||
"Return the JSON array now."
|
||||
)
|
||||
|
||||
args = [
|
||||
"claude",
|
||||
"-p",
|
||||
"--model",
|
||||
model or DEFAULT_MODEL,
|
||||
"--append-system-prompt",
|
||||
_SYSTEM_PROMPT,
|
||||
"--disable-slash-commands",
|
||||
user_message,
|
||||
]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s or DEFAULT_TIMEOUT_S,
|
||||
cwd=_sandbox_cwd(),
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error("llm_extractor_timeout", interaction_id=interaction.id)
|
||||
return LLMExtractionResult(candidates=[], raw_output="", error="timeout")
|
||||
except Exception as exc: # pragma: no cover - unexpected subprocess failure
|
||||
log.error("llm_extractor_subprocess_failed", error=str(exc))
|
||||
return LLMExtractionResult(candidates=[], raw_output="", error=f"subprocess_error: {exc}")
|
||||
|
||||
if completed.returncode != 0:
|
||||
log.error(
|
||||
"llm_extractor_nonzero_exit",
|
||||
interaction_id=interaction.id,
|
||||
returncode=completed.returncode,
|
||||
stderr_prefix=(completed.stderr or "")[:200],
|
||||
)
|
||||
return LLMExtractionResult(
|
||||
candidates=[],
|
||||
raw_output=completed.stdout or "",
|
||||
error=f"exit_{completed.returncode}",
|
||||
)
|
||||
|
||||
raw_output = (completed.stdout or "").strip()
|
||||
candidates = _parse_candidates(raw_output, interaction)
|
||||
log.info(
|
||||
"llm_extractor_done",
|
||||
interaction_id=interaction.id,
|
||||
candidate_count=len(candidates),
|
||||
model=model or DEFAULT_MODEL,
|
||||
)
|
||||
return LLMExtractionResult(candidates=candidates, raw_output=raw_output)
|
||||
|
||||
|
||||
def _parse_candidates(raw_output: str, interaction: Interaction) -> list[MemoryCandidate]:
|
||||
"""Parse the model's JSON output into MemoryCandidate objects.
|
||||
|
||||
Tolerates common model glitches: surrounding whitespace, stray
|
||||
markdown fences, leading/trailing prose. Silently drops malformed
|
||||
array elements rather than raising.
|
||||
"""
|
||||
text = raw_output.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
first_newline = text.find("\n")
|
||||
if first_newline >= 0:
|
||||
text = text[first_newline + 1 :]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
if not text or text == "[]":
|
||||
return []
|
||||
|
||||
if not text.lstrip().startswith("["):
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start >= 0 and end > start:
|
||||
text = text[start : end + 1]
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
log.error("llm_extractor_parse_failed", error=str(exc), raw_prefix=raw_output[:120])
|
||||
return []
|
||||
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
|
||||
results: list[MemoryCandidate] = []
|
||||
for item in parsed:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
mem_type = str(item.get("type") or "").strip().lower()
|
||||
content = str(item.get("content") or "").strip()
|
||||
model_project = str(item.get("project") or "").strip()
|
||||
# R9 trust hierarchy for project attribution:
|
||||
# 1. Interaction scope always wins when set (strongest signal)
|
||||
# 2. Model project used only when interaction is unscoped
|
||||
# AND model project resolves to a registered project
|
||||
# 3. Empty string when both are empty/unregistered
|
||||
if interaction.project:
|
||||
project = interaction.project
|
||||
elif model_project:
|
||||
try:
|
||||
from atocore.projects.registry import (
|
||||
load_project_registry,
|
||||
resolve_project_name,
|
||||
)
|
||||
|
||||
registered_ids = {p.project_id for p in load_project_registry()}
|
||||
resolved = resolve_project_name(model_project)
|
||||
if resolved in registered_ids:
|
||||
project = resolved
|
||||
else:
|
||||
# Unregistered project — keep the model's tag so
|
||||
# auto-triage / the operator can see it and decide
|
||||
# whether to register it as a new project or lead.
|
||||
project = model_project
|
||||
log.info(
|
||||
"unregistered_project_detected",
|
||||
model_project=model_project,
|
||||
interaction_id=interaction.id,
|
||||
)
|
||||
except Exception:
|
||||
project = model_project if model_project else ""
|
||||
else:
|
||||
project = ""
|
||||
domain = str(item.get("domain") or "").strip().lower()
|
||||
confidence_raw = item.get("confidence", 0.5)
|
||||
if mem_type not in MEMORY_TYPES:
|
||||
continue
|
||||
if not content:
|
||||
continue
|
||||
# Domain knowledge: embed the domain tag in the content so it
|
||||
# survives without a schema migration. The context builder
|
||||
# can match on it via query-relevance ranking, and a future
|
||||
# migration can parse it into a proper column.
|
||||
if domain and not project:
|
||||
content = f"[{domain}] {content}"
|
||||
try:
|
||||
confidence = float(confidence_raw)
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.5
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
results.append(
|
||||
MemoryCandidate(
|
||||
memory_type=mem_type,
|
||||
content=content[:1000],
|
||||
rule="llm_extraction",
|
||||
source_span=content[:200],
|
||||
project=project,
|
||||
confidence=confidence,
|
||||
source_interaction_id=interaction.id,
|
||||
extractor_version=LLM_EXTRACTOR_VERSION,
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -51,6 +51,15 @@ _STOP_WORDS: frozenset[str] = frozenset({
|
||||
})
|
||||
_MATCH_THRESHOLD = 0.70
|
||||
|
||||
# Long memories can't realistically hit 70% overlap through organic
|
||||
# paraphrase — a 40-token memory would need 28 stemmed tokens echoed
|
||||
# verbatim. Above this token count the matcher switches to an absolute
|
||||
# overlap floor plus a softer fraction floor so paragraph-length memories
|
||||
# still reinforce when the response genuinely uses them.
|
||||
_LONG_MEMORY_TOKEN_COUNT = 15
|
||||
_LONG_MODE_MIN_OVERLAP = 12
|
||||
_LONG_MODE_MIN_FRACTION = 0.35
|
||||
|
||||
DEFAULT_CONFIDENCE_DELTA = 0.02
|
||||
|
||||
|
||||
@@ -171,26 +180,47 @@ def _stem(word: str) -> str:
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
"""Split normalized text into a stemmed token set.
|
||||
|
||||
Strips punctuation, drops words shorter than 3 chars and stop words.
|
||||
Strips punctuation, drops words shorter than 3 chars and stop
|
||||
words. Hyphenated and slash-separated identifiers
|
||||
(``polisher-control``, ``twyman-green``, ``2-projects/interferometer``)
|
||||
produce both the full form AND each sub-token, so a query for
|
||||
"polisher control" can match a memory that wrote
|
||||
"polisher-control" without forcing callers to guess the exact
|
||||
hyphenation.
|
||||
"""
|
||||
tokens: set[str] = set()
|
||||
for raw in text.split():
|
||||
# Strip leading/trailing punctuation (commas, periods, quotes, etc.)
|
||||
word = raw.strip(".,;:!?\"'()[]{}-/")
|
||||
if len(word) < 3:
|
||||
if not word:
|
||||
continue
|
||||
if word in _STOP_WORDS:
|
||||
continue
|
||||
tokens.add(_stem(word))
|
||||
_add_token(tokens, word)
|
||||
# Also add sub-tokens split on internal '-' or '/' so
|
||||
# hyphenated identifiers match queries that don't hyphenate.
|
||||
if "-" in word or "/" in word:
|
||||
for sub in re.split(r"[-/]+", word):
|
||||
_add_token(tokens, sub)
|
||||
return tokens
|
||||
|
||||
|
||||
def _add_token(tokens: set[str], word: str) -> None:
|
||||
if len(word) < 3:
|
||||
return
|
||||
if word in _STOP_WORDS:
|
||||
return
|
||||
tokens.add(_stem(word))
|
||||
|
||||
|
||||
def _memory_matches(memory_content: str, normalized_response: str) -> bool:
|
||||
"""Return True if enough of the memory's tokens appear in the response.
|
||||
|
||||
Uses token-overlap: tokenize both sides (lowercase, stem, drop stop
|
||||
words), then check whether >= 70 % of the memory's content tokens
|
||||
appear in the response token set.
|
||||
Dual-mode token overlap:
|
||||
- Short memories (<= _LONG_MEMORY_TOKEN_COUNT stems): require
|
||||
>= 70 % of memory tokens echoed.
|
||||
- Long memories (paragraphs): require an absolute floor of
|
||||
_LONG_MODE_MIN_OVERLAP distinct stems echoed AND a softer
|
||||
fraction of _LONG_MODE_MIN_FRACTION, so organic paraphrase
|
||||
of a real project memory can reinforce without the response
|
||||
quoting the paragraph verbatim.
|
||||
"""
|
||||
if not memory_content:
|
||||
return False
|
||||
@@ -202,4 +232,10 @@ def _memory_matches(memory_content: str, normalized_response: str) -> bool:
|
||||
return False
|
||||
response_tokens = _tokenize(normalized_response)
|
||||
overlap = memory_tokens & response_tokens
|
||||
return len(overlap) / len(memory_tokens) >= _MATCH_THRESHOLD
|
||||
fraction = len(overlap) / len(memory_tokens)
|
||||
if len(memory_tokens) <= _LONG_MEMORY_TOKEN_COUNT:
|
||||
return fraction >= _MATCH_THRESHOLD
|
||||
return (
|
||||
len(overlap) >= _LONG_MODE_MIN_OVERLAP
|
||||
and fraction >= _LONG_MODE_MIN_FRACTION
|
||||
)
|
||||
|
||||
@@ -344,6 +344,9 @@ def get_memories_for_context(
|
||||
memory_types: list[str] | None = None,
|
||||
project: str | None = None,
|
||||
budget: int = 500,
|
||||
header: str = "--- AtoCore Memory ---",
|
||||
footer: str = "--- End Memory ---",
|
||||
query: str | None = None,
|
||||
) -> tuple[str, int]:
|
||||
"""Get formatted memories for context injection.
|
||||
|
||||
@@ -351,38 +354,81 @@ def get_memories_for_context(
|
||||
|
||||
Budget allocation per Master Plan section 9:
|
||||
identity: 5%, preference: 5%, rest from retrieval budget
|
||||
|
||||
The caller can override ``header`` / ``footer`` to distinguish
|
||||
multiple memory blocks in the same pack (e.g. identity/preference
|
||||
vs project/knowledge memories).
|
||||
|
||||
When ``query`` is provided, candidates within each memory type
|
||||
are ranked by lexical overlap against the query (stemmed token
|
||||
intersection, ties broken by confidence). Without a query,
|
||||
candidates fall through in the order ``get_memories`` returns
|
||||
them — which is effectively "by confidence desc".
|
||||
"""
|
||||
if memory_types is None:
|
||||
memory_types = ["identity", "preference"]
|
||||
|
||||
if budget <= 0:
|
||||
return "", 0
|
||||
|
||||
header = "--- AtoCore Memory ---"
|
||||
footer = "--- End Memory ---"
|
||||
wrapper_chars = len(header) + len(footer) + 2
|
||||
if budget <= wrapper_chars:
|
||||
return "", 0
|
||||
|
||||
available = budget - wrapper_chars
|
||||
selected_entries: list[str] = []
|
||||
used = 0
|
||||
|
||||
for index, mtype in enumerate(memory_types):
|
||||
type_budget = available if index == len(memory_types) - 1 else max(0, available // (len(memory_types) - index))
|
||||
type_used = 0
|
||||
# Pre-tokenize the query once. ``_score_memory_for_query`` is a
|
||||
# free function below that reuses the reinforcement tokenizer so
|
||||
# lexical scoring here matches the reinforcement matcher.
|
||||
query_tokens: set[str] | None = None
|
||||
if query:
|
||||
from atocore.memory.reinforcement import _normalize, _tokenize
|
||||
|
||||
query_tokens = _tokenize(_normalize(query))
|
||||
if not query_tokens:
|
||||
query_tokens = None
|
||||
|
||||
# Collect ALL candidates across the requested types into one
|
||||
# pool, then rank globally before the budget walk. Ranking per
|
||||
# type and walking types in order would starve later types when
|
||||
# the first type's candidates filled the budget — even if a
|
||||
# later-type candidate matched the query perfectly. Type order
|
||||
# is preserved as a stable tiebreaker inside
|
||||
# ``_rank_memories_for_query`` via Python's stable sort.
|
||||
pool: list[Memory] = []
|
||||
seen_ids: set[str] = set()
|
||||
for mtype in memory_types:
|
||||
for mem in get_memories(
|
||||
memory_type=mtype,
|
||||
project=project,
|
||||
min_confidence=0.5,
|
||||
limit=10,
|
||||
limit=30,
|
||||
):
|
||||
entry = f"[{mem.memory_type}] {mem.content}"
|
||||
entry_len = len(entry) + 1
|
||||
if entry_len > type_budget - type_used:
|
||||
if mem.id in seen_ids:
|
||||
continue
|
||||
selected_entries.append(entry)
|
||||
type_used += entry_len
|
||||
available -= type_used
|
||||
seen_ids.add(mem.id)
|
||||
pool.append(mem)
|
||||
|
||||
if query_tokens is not None:
|
||||
pool = _rank_memories_for_query(pool, query_tokens)
|
||||
|
||||
# Per-entry cap prevents a single long memory from monopolizing
|
||||
# the band. With 16 p06 memories competing for ~700 chars, an
|
||||
# uncapped 530-char overview memory fills the entire budget before
|
||||
# a query-relevant 150-char memory gets a slot. The cap ensures at
|
||||
# least 2-3 entries fit regardless of individual memory length.
|
||||
max_entry_chars = 250
|
||||
for mem in pool:
|
||||
content = mem.content
|
||||
if len(content) > max_entry_chars:
|
||||
content = content[:max_entry_chars - 3].rstrip() + "..."
|
||||
entry = f"[{mem.memory_type}] {content}"
|
||||
entry_len = len(entry) + 1
|
||||
if entry_len > available - used:
|
||||
continue
|
||||
selected_entries.append(entry)
|
||||
used += entry_len
|
||||
|
||||
if not selected_entries:
|
||||
return "", 0
|
||||
@@ -394,6 +440,35 @@ def get_memories_for_context(
|
||||
return text, len(text)
|
||||
|
||||
|
||||
def _rank_memories_for_query(
|
||||
memories: list["Memory"],
|
||||
query_tokens: set[str],
|
||||
) -> list["Memory"]:
|
||||
"""Rerank a memory list by lexical overlap with a pre-tokenized query.
|
||||
|
||||
Primary key: overlap_density (overlap_count / memory_token_count),
|
||||
which rewards short focused memories that match the query precisely
|
||||
over long overview memories that incidentally share a few tokens.
|
||||
Secondary: absolute overlap count. Tertiary: confidence.
|
||||
|
||||
R7 fix: previously overlap_count alone was the primary key, so a
|
||||
40-token overview memory with 3 overlapping tokens tied a 5-token
|
||||
memory with 3 overlapping tokens, and the overview won on
|
||||
confidence. Now the short memory's density (0.6) beats the
|
||||
overview's density (0.075).
|
||||
"""
|
||||
from atocore.memory.reinforcement import _normalize, _tokenize
|
||||
|
||||
scored: list[tuple[float, int, float, Memory]] = []
|
||||
for mem in memories:
|
||||
mem_tokens = _tokenize(_normalize(mem.content))
|
||||
overlap = len(mem_tokens & query_tokens) if mem_tokens else 0
|
||||
density = overlap / len(mem_tokens) if mem_tokens else 0.0
|
||||
scored.append((density, overlap, mem.confidence, mem))
|
||||
scored.sort(key=lambda t: (t[0], t[1], t[2]), reverse=True)
|
||||
return [mem for _, _, _, mem in scored]
|
||||
|
||||
|
||||
def _row_to_memory(row) -> Memory:
|
||||
"""Convert a DB row to Memory dataclass."""
|
||||
keys = row.keys() if hasattr(row, "keys") else []
|
||||
|
||||
234
t420-openclaw/AGENTS.md
Normal file
234
t420-openclaw/AGENTS.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# AGENTS.md - Your Workspace
|
||||
|
||||
This folder is home. Treat it that way.
|
||||
|
||||
## First Run
|
||||
|
||||
If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again.
|
||||
|
||||
## Every Session
|
||||
|
||||
Before doing anything else:
|
||||
1. Read `SOUL.md` — this is who you are
|
||||
2. Read `USER.md` — this is who you're helping
|
||||
3. Read `MODEL-ROUTING.md` — follow the auto-routing policy for model selection
|
||||
4. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
|
||||
5. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
|
||||
|
||||
Don't ask permission. Just do it.
|
||||
|
||||
## Memory
|
||||
|
||||
You wake up fresh each session. These files are your continuity:
|
||||
- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened
|
||||
- **Long-term:** `MEMORY.md` — your curated memories, like a human's long-term memory
|
||||
|
||||
Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them.
|
||||
|
||||
### 🧠 MEMORY.md - Your Long-Term Memory
|
||||
- **ONLY load in main session** (direct chats with your human)
|
||||
- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people)
|
||||
- This is for **security** — contains personal context that shouldn't leak to strangers
|
||||
- You can **read, edit, and update** MEMORY.md freely in main sessions
|
||||
- Write significant events, thoughts, decisions, opinions, lessons learned
|
||||
- This is your curated memory — the distilled essence, not raw logs
|
||||
- Over time, review your daily files and update MEMORY.md with what's worth keeping
|
||||
|
||||
### 📝 Write It Down - No "Mental Notes"!
|
||||
- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE
|
||||
- "Mental notes" don't survive session restarts. Files do.
|
||||
- When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file
|
||||
- When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill
|
||||
- When you make a mistake → document it so future-you doesn't repeat it
|
||||
- **Text > Brain** 📝
|
||||
|
||||
## Safety
|
||||
|
||||
- Don't exfiltrate private data. Ever.
|
||||
- Don't run destructive commands without asking.
|
||||
- `trash` > `rm` (recoverable beats gone forever)
|
||||
- When in doubt, ask.
|
||||
|
||||
## External vs Internal
|
||||
|
||||
**Safe to do freely:**
|
||||
- Read files, explore, organize, learn
|
||||
- Search the web, check calendars
|
||||
- Work within this workspace
|
||||
|
||||
**Ask first:**
|
||||
- Sending emails, tweets, public posts
|
||||
- Anything that leaves the machine
|
||||
- Anything you're uncertain about
|
||||
|
||||
## Group Chats
|
||||
|
||||
You have access to your human's stuff. That doesn't mean you *share* their stuff. In groups, you're a participant — not their voice, not their proxy. Think before you speak.
|
||||
|
||||
### 💬 Know When to Speak!
|
||||
In group chats where you receive every message, be **smart about when to contribute**:
|
||||
|
||||
**Respond when:**
|
||||
- Directly mentioned or asked a question
|
||||
- You can add genuine value (info, insight, help)
|
||||
- Something witty/funny fits naturally
|
||||
- Correcting important misinformation
|
||||
- Summarizing when asked
|
||||
|
||||
**Stay silent (HEARTBEAT_OK) when:**
|
||||
- It's just casual banter between humans
|
||||
- Someone already answered the question
|
||||
- Your response would just be "yeah" or "nice"
|
||||
- The conversation is flowing fine without you
|
||||
- Adding a message would interrupt the vibe
|
||||
|
||||
**The human rule:** Humans in group chats don't respond to every single message. Neither should you. Quality > quantity. If you wouldn't send it in a real group chat with friends, don't send it.
|
||||
|
||||
**Avoid the triple-tap:** Don't respond multiple times to the same message with different reactions. One thoughtful response beats three fragments.
|
||||
|
||||
Participate, don't dominate.
|
||||
|
||||
### 😊 React Like a Human!
|
||||
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
|
||||
|
||||
**React when:**
|
||||
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
|
||||
- Something made you laugh (😂, 💀)
|
||||
- You find it interesting or thought-provoking (🤔, 💡)
|
||||
- You want to acknowledge without interrupting the flow
|
||||
- It's a simple yes/no or approval situation (✅, 👀)
|
||||
|
||||
**Why it matters:**
|
||||
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
|
||||
|
||||
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
|
||||
|
||||
## Tools
|
||||
|
||||
When a task is contextual and project-dependent, use the `atocore-context` skill to query Dalidou-hosted AtoCore for trusted project state, retrieval, context-building, registered project refresh, or project registration discovery when that will improve accuracy. Treat AtoCore as additive and fail-open; do not replace OpenClaw's own memory with it. Prefer `projects` and `refresh-project <id>` when a known project needs a clean source refresh, and use `project-template` when proposing a new project registration, and `propose-project ...` when you want a normalized preview before editing the registry manually.
|
||||
|
||||
### Organic AtoCore Routing
|
||||
|
||||
For normal project knowledge questions, use AtoCore by default without waiting for the human to ask for the helper explicitly.
|
||||
|
||||
Use AtoCore first when the prompt:
|
||||
- mentions a registered project id or alias
|
||||
- asks about architecture, constraints, status, requirements, vendors, planning, prior decisions, or current project truth
|
||||
- would benefit from cross-source context instead of only the local repo
|
||||
|
||||
Preferred flow:
|
||||
1. `auto-context "<prompt>" 3000` for most project knowledge questions
|
||||
2. `project-state <project>` when the user is clearly asking for trusted current truth
|
||||
3. `audit-query "<prompt>" 5 [project]` when broad prompts drift, archive/history noise appears, or retrieval quality is being evaluated
|
||||
4. `refresh-project <id>` before answering if the user explicitly asked to refresh or ingest project changes
|
||||
|
||||
For AtoCore improvement work, prefer this sequence:
|
||||
1. retrieval-quality pass
|
||||
2. Wave 2 trusted-operational ingestion
|
||||
3. AtoDrive clarification
|
||||
4. restore and ops validation
|
||||
|
||||
Wave 2 trusted-operational truth should prioritize:
|
||||
- current status
|
||||
- current decisions
|
||||
- requirements baseline
|
||||
- milestone plan
|
||||
- next actions
|
||||
|
||||
Do not ingest the whole PKM vault before the trusted-operational layer is in good shape. Treat AtoDrive as curated operational truth, not a generic dump.
|
||||
|
||||
Do not force AtoCore for purely local coding actions like fixing a function, editing one file, or running tests, unless broader project context is likely to matter.
|
||||
|
||||
If `auto-context` returns `no_project_match` or AtoCore is unavailable, continue normally with OpenClaw's own tools and memory.
|
||||
|
||||
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
|
||||
|
||||
**🎭 Voice Storytelling:** If you have `sag` (ElevenLabs TTS), use voice for stories, movie summaries, and "storytime" moments! Way more engaging than walls of text. Surprise people with funny voices.
|
||||
|
||||
**📝 Platform Formatting:**
|
||||
- **Discord/WhatsApp:** No markdown tables! Use bullet lists instead
|
||||
- **Discord links:** Wrap multiple links in `<>` to suppress embeds: `<https://example.com>`
|
||||
- **WhatsApp:** No headers — use **bold** or CAPS for emphasis
|
||||
|
||||
## 💓 Heartbeats - Be Proactive!
|
||||
|
||||
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
|
||||
|
||||
Default heartbeat prompt:
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
|
||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
||||
|
||||
### Heartbeat vs Cron: When to Use Each
|
||||
|
||||
**Use heartbeat when:**
|
||||
- Multiple checks can batch together (inbox + calendar + notifications in one turn)
|
||||
- You need conversational context from recent messages
|
||||
- Timing can drift slightly (every ~30 min is fine, not exact)
|
||||
- You want to reduce API calls by combining periodic checks
|
||||
|
||||
**Use cron when:**
|
||||
- Exact timing matters ("9:00 AM sharp every Monday")
|
||||
- Task needs isolation from main session history
|
||||
- You want a different model or thinking level for the task
|
||||
- One-shot reminders ("remind me in 20 minutes")
|
||||
- Output should deliver directly to a channel without main session involvement
|
||||
|
||||
**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
|
||||
|
||||
**Things to check (rotate through these, 2-4 times per day):**
|
||||
- **Emails** - Any urgent unread messages?
|
||||
- **Calendar** - Upcoming events in next 24-48h?
|
||||
- **Mentions** - Twitter/social notifications?
|
||||
- **Weather** - Relevant if your human might go out?
|
||||
|
||||
**Track your checks** in `memory/heartbeat-state.json`:
|
||||
```json
|
||||
{
|
||||
"lastChecks": {
|
||||
"email": 1703275200,
|
||||
"calendar": 1703260800,
|
||||
"weather": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to reach out:**
|
||||
- Important email arrived
|
||||
- Calendar event coming up (<2h)
|
||||
- Something interesting you found
|
||||
- It's been >8h since you said anything
|
||||
|
||||
**When to stay quiet (HEARTBEAT_OK):**
|
||||
- Late night (23:00-08:00) unless urgent
|
||||
- Human is clearly busy
|
||||
- Nothing new since last check
|
||||
- You just checked <30 minutes ago
|
||||
|
||||
**Proactive work you can do without asking:**
|
||||
- Read and organize memory files
|
||||
- Check on projects (git status, etc.)
|
||||
- Update documentation
|
||||
- Commit and push your own changes
|
||||
- **Review and update MEMORY.md** (see below)
|
||||
|
||||
### 🔄 Memory Maintenance (During Heartbeats)
|
||||
Periodically (every few days), use a heartbeat to:
|
||||
1. Read through recent `memory/YYYY-MM-DD.md` files
|
||||
2. Identify significant events, lessons, or insights worth keeping long-term
|
||||
3. Update `MEMORY.md` with distilled learnings
|
||||
4. Remove outdated info from MEMORY.md that's no longer relevant
|
||||
|
||||
Think of it like a human reviewing their journal and updating their mental model. Daily files are raw notes; MEMORY.md is curated wisdom.
|
||||
|
||||
The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time.
|
||||
|
||||
## Make It Yours
|
||||
|
||||
This is a starting point. Add your own conventions, style, and rules as you figure out what works.
|
||||
|
||||
## Orchestration Completion Protocol
|
||||
After any orchestration chain completes (research → review → condensation):
|
||||
1. Secretary MUST be the final agent tasked
|
||||
2. Secretary produces the condensation file AND posts a distillate to Discord #reports
|
||||
3. Manager should include in Secretary's task: "Post a distillate to Discord #reports summarizing this orchestration"
|
||||
133
t420-openclaw/ATOCORE-OPERATIONS.md
Normal file
133
t420-openclaw/ATOCORE-OPERATIONS.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# AtoCore Operations
|
||||
|
||||
This is the current operating playbook for making AtoCore more dependable and higher-signal.
|
||||
|
||||
## Order of Work
|
||||
|
||||
1. Retrieval-quality pass
|
||||
2. Wave 2 trusted-operational ingestion
|
||||
3. AtoDrive clarification
|
||||
4. Restore and ops validation
|
||||
|
||||
## 1. Retrieval-Quality Pass
|
||||
|
||||
Observed behavior from the live service:
|
||||
|
||||
- broad prompts like `gigabit` and `polisher` still surface archive/history noise
|
||||
- meaningful prompts like `mirror frame stiffness requirements and selected architecture` are much sharper
|
||||
- now that the corpus is large enough, ranking quality matters more than raw corpus presence
|
||||
|
||||
Use these commands first:
|
||||
|
||||
```bash
|
||||
python atocore.py audit-query "gigabit" 5
|
||||
python atocore.py audit-query "polisher" 5
|
||||
python atocore.py audit-query "mirror frame stiffness requirements and selected architecture" 5 p04-gigabit
|
||||
python atocore.py audit-query "interferometer error budget and vendor selection constraints" 5 p05-interferometer
|
||||
python atocore.py audit-query "polisher system map shared contracts and calibration workflow" 5 p06-polisher
|
||||
```
|
||||
|
||||
What to fix in the retrieval pass:
|
||||
|
||||
- reduce `_archive`, `pre-cleanup`, `pre-migration`, and `History` prominence
|
||||
- prefer current-status, decision, requirement, architecture-freeze, and milestone docs
|
||||
- prefer trusted project-state over freeform notes when both speak to current truth
|
||||
- keep broad prompts from matching stale or generic chunks too easily
|
||||
|
||||
Suggested acceptance bar:
|
||||
|
||||
- top 5 for active-project prompts contain at least one current-status or next-focus item
|
||||
- top 5 contain at least one decision or architecture-baseline item
|
||||
- top 5 contain at least one requirement or constraints item
|
||||
- broad single-word prompts no longer lead with archive/history chunks
|
||||
|
||||
## 2. Wave 2 Trusted-Operational Ingestion
|
||||
|
||||
Do not ingest the whole PKM vault next.
|
||||
|
||||
Wave 2 should ingest trusted operational truth for each active project:
|
||||
|
||||
- current status dashboard or status note
|
||||
- current decisions / decision log
|
||||
- requirements baseline
|
||||
- architecture freeze / current baseline
|
||||
- milestone plan
|
||||
- next actions / near-term focus
|
||||
|
||||
Recommended helper flow:
|
||||
|
||||
```bash
|
||||
python atocore.py project-state p04-gigabit
|
||||
python atocore.py project-state p05-interferometer
|
||||
python atocore.py project-state p06-polisher
|
||||
python atocore.py project-state-set p04-gigabit status next_focus "Continue curated support and frame-context buildout." "Wave 2 status dashboard" 1.0
|
||||
python atocore.py project-state-set p05-interferometer requirement key_constraints "Preserve current error-budget, thermal, and vendor-selection constraints as the working baseline." "Wave 2 requirements baseline" 1.0
|
||||
python atocore.py project-state-set p06-polisher decision system_boundary "The suite remains a three-layer chain with explicit planning, translation, and execution boundaries." "Wave 2 decision log" 1.0
|
||||
python atocore.py refresh-project p04-gigabit
|
||||
python atocore.py refresh-project p05-interferometer
|
||||
python atocore.py refresh-project p06-polisher
|
||||
```
|
||||
|
||||
Use project-state for the most authoritative "current truth" fields, then refresh the registered project roots after curated Wave 2 documents land.
|
||||
|
||||
## 3. AtoDrive Clarification
|
||||
|
||||
AtoDrive should become a trusted-operational source, not a generic corpus dump.
|
||||
|
||||
Good AtoDrive candidates:
|
||||
|
||||
- current dashboards
|
||||
- current baselines
|
||||
- approved architecture docs
|
||||
- decision logs
|
||||
- milestone and next-step views
|
||||
- operational source-of-truth files that humans actively maintain
|
||||
|
||||
Avoid as default AtoDrive ingest:
|
||||
|
||||
- large generic archives
|
||||
- duplicated exports
|
||||
- stale snapshots when a newer baseline exists
|
||||
- exploratory notes that are not designated current truth
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- if the file answers "what is true now?" it may belong in trusted-operational
|
||||
- if the file mostly answers "what did we think at some point?" it belongs in the broader corpus, not Wave 2
|
||||
|
||||
## 4. Restore and Ops Validation
|
||||
|
||||
Backups are not enough until restore has been tested.
|
||||
|
||||
Validate these explicitly:
|
||||
|
||||
- SQLite metadata restore
|
||||
- Chroma restore or rebuild
|
||||
- registry restore
|
||||
- source-root refresh after restore
|
||||
- health and stats consistency after recovery
|
||||
|
||||
Recommended restore drill:
|
||||
|
||||
1. Record current `health`, `stats`, and `projects` output.
|
||||
2. Restore SQLite metadata and project registry from backup.
|
||||
3. Decide whether Chroma is restored from backup or rebuilt from source.
|
||||
4. Run project refresh for active projects.
|
||||
5. Compare vector/doc counts and run retrieval audits again.
|
||||
|
||||
Commands to capture the before/after baseline:
|
||||
|
||||
```bash
|
||||
python atocore.py health
|
||||
python atocore.py stats
|
||||
python atocore.py projects
|
||||
python atocore.py audit-query "gigabit" 5
|
||||
python atocore.py audit-query "interferometer error budget and vendor selection constraints" 5 p05-interferometer
|
||||
```
|
||||
|
||||
Recovery policy decision still needed:
|
||||
|
||||
- prefer Chroma backup restore for fast recovery when backup integrity is trusted
|
||||
- prefer Chroma rebuild when backups are suspect, schema changed, or ranking behavior drifts unexpectedly
|
||||
|
||||
The important part is to choose one policy on purpose and validate it, not leave it implicit.
|
||||
105
t420-openclaw/SKILL.md
Normal file
105
t420-openclaw/SKILL.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: atocore-context
|
||||
description: Use Dalidou-hosted AtoCore as a read-only external context service for project state, retrieval, and context-building without touching OpenClaw's own memory.
|
||||
---
|
||||
|
||||
# AtoCore Context
|
||||
|
||||
Use this skill when you need trusted project context, retrieval help, or AtoCore
|
||||
health/status from the canonical Dalidou instance.
|
||||
|
||||
## Purpose
|
||||
|
||||
AtoCore is an additive external context service.
|
||||
|
||||
- It does not replace OpenClaw's own memory.
|
||||
- It should be used for contextual work, not trivial prompts.
|
||||
- It is read-only in this first integration batch.
|
||||
- If AtoCore is unavailable, continue normally.
|
||||
|
||||
## Canonical Endpoint
|
||||
|
||||
Default base URL:
|
||||
|
||||
```bash
|
||||
http://dalidou:8100
|
||||
```
|
||||
|
||||
Override with:
|
||||
|
||||
```bash
|
||||
ATOCORE_BASE_URL=http://host:port
|
||||
```
|
||||
|
||||
## Safe Usage
|
||||
|
||||
Use AtoCore for:
|
||||
- project-state checks
|
||||
- automatic project detection for normal project questions
|
||||
- retrieval-quality audits before declaring a project corpus "good enough"
|
||||
- retrieval over ingested project/ecosystem docs
|
||||
- context-building for complex project prompts
|
||||
- verifying current AtoCore hosting and architecture state
|
||||
- listing registered projects and refreshing a known project source set
|
||||
- inspecting the project registration template before proposing a new project entry
|
||||
- generating a proposal preview for a new project registration without writing it
|
||||
- registering an approved project entry when explicitly requested
|
||||
- updating an existing registered project when aliases or description need refinement
|
||||
|
||||
Do not use AtoCore for:
|
||||
- automatic memory write-back
|
||||
- replacing OpenClaw memory
|
||||
- silent ingestion of broad new corpora without approval
|
||||
- ingesting the whole PKM vault before trusted operational truth is staged
|
||||
- mutating the registry automatically without human approval
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh health
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh sources
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh stats
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh projects
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh project-template
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh detect-project "what's the interferometer error budget?"
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh auto-context "what's the interferometer error budget?" 3000
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh debug-context
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh audit-query "gigabit" 5
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh audit-query "mirror frame stiffness requirements and selected architecture" 5 p04-gigabit
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh propose-project p07-example "p07,example-project" vault incoming/projects/p07-example "Example project" "Primary staged project docs"
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh register-project p07-example "p07,example-project" vault incoming/projects/p07-example "Example project" "Primary staged project docs"
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh update-project p05 "Curated staged docs for the P05 interferometer architecture, vendors, and error-budget project."
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh refresh-project p05
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh project-state atocore
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh project-state-set p05-interferometer status next_focus "Freeze current error-budget baseline and vendor downselect." "Wave 2 status dashboard" 1.0
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh project-state-invalidate p05-interferometer status next_focus
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh query "What is AtoDrive?"
|
||||
~/clawd/skills/atocore-context/scripts/atocore.sh context-build "Need current AtoCore architecture" atocore 3000
|
||||
```
|
||||
|
||||
Direct Python entrypoint for non-Bash environments:
|
||||
|
||||
```bash
|
||||
python ~/clawd/skills/atocore-context/scripts/atocore.py health
|
||||
```
|
||||
|
||||
## Contract
|
||||
|
||||
- prefer AtoCore only when additional context is genuinely useful
|
||||
- trust AtoCore as additive context, not as a hard runtime dependency
|
||||
- fail open if the service errors or times out
|
||||
- cite when information came from AtoCore rather than local OpenClaw memory
|
||||
- for normal project knowledge questions, prefer `auto-context "<prompt>" 3000` before answering
|
||||
- use `detect-project "<prompt>"` when you want to inspect project inference explicitly
|
||||
- use `debug-context` right after `auto-context` or `context-build` when you want
|
||||
to inspect the exact last AtoCore context pack
|
||||
- use `audit-query "<prompt>" 5 [project]` when retrieval quality is in question, especially for broad prompts
|
||||
- prefer `projects` plus `refresh-project <id>` over long ad hoc ingest instructions when the project is already registered
|
||||
- use `project-template` when preparing a new project registration proposal
|
||||
- use `propose-project ...` to draft a normalized entry and review collisions first
|
||||
- use `register-project ...` only after the proposal has been reviewed and approved
|
||||
- use `update-project ...` when a registered project's description or aliases need refinement before refresh
|
||||
- use `project-state-set` for trusted operational truth such as current status, current decisions, frozen requirements, milestone baselines, and next actions
|
||||
- do Wave 2 before broad PKM expansion: status dashboards, decision logs, milestone views, current baseline docs, and next-step views
|
||||
- treat AtoDrive as a curated trusted-operational source, not a generic dump of miscellaneous drive files
|
||||
- validate restore posture explicitly; a backup is not trusted until restore or rebuild steps have been exercised successfully
|
||||
279
t420-openclaw/TOOLS.md
Normal file
279
t420-openclaw/TOOLS.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# TOOLS.md - Local Notes
|
||||
|
||||
|
||||
## AtoCore (External Context Service)
|
||||
|
||||
- **Canonical Host:** http://dalidou:8100
|
||||
- **Role:** Read-only external context service for trusted project state, retrieval, context-building, registered project refresh, project registration discovery, and retrieval-quality auditing
|
||||
- **Machine state lives on:** Dalidou (/srv/storage/atocore/data/...)
|
||||
- **Rule:** Use AtoCore as additive context only; do not treat it as a replacement for OpenClaw memory
|
||||
- **Helper script:** /home/papa/clawd/skills/atocore-context/scripts/atocore.sh
|
||||
- **Python fallback:** `/home/papa/clawd/skills/atocore-context/scripts/atocore.py` for non-Bash environments
|
||||
- **Key commands:** `projects`, `project-template`, `detect-project "<prompt>"`, `auto-context "<prompt>" [budget] [project]`, `debug-context`, `audit-query "<prompt>" [top_k] [project]`, `propose-project ...`, `register-project ...`, `update-project <id> "description" ["aliases"]`, `refresh-project <id>`, `project-state <id> [category]`, `project-state-set <project> <category> <key> <value> [source] [confidence]`, `project-state-invalidate <project> <category> <key>`, `context-build ...`
|
||||
- **Fail-open rule:** If AtoCore is unavailable, continue normal OpenClaw behavior
|
||||
|
||||
### Organic Usage Rule
|
||||
|
||||
- For normal project knowledge questions, try `auto-context` first.
|
||||
- For retrieval complaints or broad-prompt drift, run `audit-query` before changing ingestion scope.
|
||||
- Use `project-state` when you want trusted current truth only.
|
||||
- Use `project-state-set` for current status, current decisions, baseline requirements, milestone views, and next actions.
|
||||
- Use `query` for quick probing/debugging.
|
||||
- Use `context-build` when you already know the project and want the exact context pack.
|
||||
- Use `debug-context` right after `auto-context` or `context-build` if you want
|
||||
to inspect the exact AtoCore supplement being fed into the workflow.
|
||||
- Do Wave 2 trusted-operational ingestion before broad PKM expansion.
|
||||
- Treat AtoDrive as a curated operational-truth source, not a generic bulk ingest target.
|
||||
- Keep purely local coding tasks local unless broader project context is likely to help.
|
||||
|
||||
## PKM / Obsidian Vault
|
||||
|
||||
- **Local Path:** `/home/papa/obsidian-vault/`
|
||||
- **Name:** Antoine Brain Extension
|
||||
- **Sync:** Syncthing (syncs from dalidou)
|
||||
- **Access:** ✅ Direct local access — no SSH needed!
|
||||
|
||||
## ATODrive (Work Documents)
|
||||
|
||||
- **Local Path:** `/home/papa/ATODrive/`
|
||||
- **Sync:** Syncthing (syncs from dalidou SeaDrive)
|
||||
- **Access:** ✅ Direct local access
|
||||
|
||||
## Atomaste (Business/Templates)
|
||||
|
||||
- **Local Path:** `/home/papa/Atomaste/`
|
||||
- **Sync:** Syncthing (syncs from dalidou SeaDrive)
|
||||
- **Access:** ✅ Direct local access
|
||||
|
||||
## Atomaste Finance (Canonical Expense System)
|
||||
|
||||
- **Single home:** `/home/papa/Atomaste/03_Finances/Expenses/`
|
||||
- **Rule:** If it is expense-related, it belongs under `Expenses/`, not `Documents/Receipts/`
|
||||
- **Per-year structure:**
|
||||
- `YYYY/Inbox/` — unprocessed incoming receipts/screenshots
|
||||
- `YYYY/receipts/` — final home for processed raw receipt files
|
||||
- `YYYY/expenses_master.csv` — main structured expense table
|
||||
- `YYYY/reports/` — derived summaries, exports, tax packages
|
||||
- **Workflow:** Inbox → `expenses_master.csv` → `receipts/`
|
||||
- **Legacy path:** `/home/papa/Atomaste/03_Finances/Documents/Receipts/` is deprecated and should stay unused except for the migration note
|
||||
|
||||
## Odile Inc (Corporate)
|
||||
|
||||
- **Local Path:** `/home/papa/Odile Inc/`
|
||||
- **Sync:** Syncthing (syncs from dalidou SeaDrive `My Libraries\Odile\Odile Inc`)
|
||||
- **Access:** ✅ Direct local access
|
||||
- **Entity:** Odile Bérubé O.D. Inc. (SPCC, optometrist)
|
||||
- **Fiscal year end:** July 31
|
||||
- **Structure:** `01_Finances/` (BankStatements, Expenses, Payroll, Revenue, Taxes), `02_Admin/`, `Inbox/`
|
||||
- **Rule:** Corporate docs go here, personal docs go to `Impôts Odile/Dossier_Fiscal_YYYY/`
|
||||
|
||||
## Impôts Odile (Personal Tax)
|
||||
|
||||
- **Local Path:** `/home/papa/Impôts Odile/`
|
||||
- **Sync:** Syncthing (syncs from dalidou SeaDrive)
|
||||
- **Access:** ✅ Direct local access
|
||||
- **Structure:** `Dossier_Fiscal_YYYY/` (9 sections: revenus, dépenses, crédits, feuillets, REER, dons, comptable, frais médicaux, budget)
|
||||
- **Cron:** Monthly receipt processing (1st of month, 2 PM ET) scans mario@atomaste.ca for Odile's emails
|
||||
|
||||
## Git Repos (via Gitea)
|
||||
|
||||
- **Gitea URL:** http://100.80.199.40:3000
|
||||
- **Auth:** Token in `~/.gitconfig` — **ALWAYS use auth for API calls** (private repos won't show without it)
|
||||
- **API Auth Header:** `Authorization: token $(git config --get credential.http://100.80.199.40:3000.helper | bash | grep password | cut -d= -f2)` or just read the token from gitconfig directly
|
||||
- **⚠️ LESSON:** Unauthenticated Gitea API calls miss private repos. Always authenticate.
|
||||
- **Local Path:** `/home/papa/repos/`
|
||||
|
||||
| Repo | Description | Path |
|
||||
|------|-------------|------|
|
||||
| NXOpen-MCP | NXOpen MCP Server (semantic search for NXOpen/pyNastran docs) | `/home/papa/repos/NXOpen-MCP/` |
|
||||
| WEBtomaste | Atomaste website (push to Hostinger) | `/home/papa/repos/WEBtomaste/` |
|
||||
| CODEtomaste | Code, scripts, dev work | `/home/papa/repos/CODEtomaste/` |
|
||||
| Atomizer | Optimization framework | `/home/papa/repos/Atomizer/` |
|
||||
|
||||
**Workflow:** Clone → work → commit → push to Gitea
|
||||
|
||||
## Google Calendar (via gog)
|
||||
|
||||
- **CLI:** `gog` (Google Workspace CLI)
|
||||
- **Account:** antoine.letarte@gmail.com
|
||||
- **Scopes:** Calendar only (no Gmail, Drive, etc.)
|
||||
- **Commands:**
|
||||
- `gog calendar events --max 10` — List upcoming events
|
||||
- `gog calendar calendars` — List calendars
|
||||
- `gog calendar create --summary "Meeting" --start "2026-01-28T10:00:00"` — Create event
|
||||
|
||||
### Vault Structure (PARA)
|
||||
```
|
||||
obsidian/
|
||||
├── 0-Inbox/ # Quick captures, process weekly
|
||||
├── 1-Areas/ # Ongoing responsibilities
|
||||
│ ├── Personal/ # Finance, Health, Family, Home
|
||||
│ └── Professional/ # Atomaste/, Engineering/
|
||||
├── 2-Projects/ # Active work with deadlines
|
||||
│ ├── P04-GigaBIT-M1/ # Current main project (StarSpec)
|
||||
│ ├── Atomizer-AtomasteAI/
|
||||
│ └── _Archive/ # Completed projects
|
||||
├── 3-Resources/ # Reference material
|
||||
│ ├── People/ # Clients, Suppliers, Colleagues
|
||||
│ ├── Tools/ # Software, Hardware guides
|
||||
│ └── Concepts/ # Technical concepts
|
||||
├── 4-Calendar/ # Time-based notes
|
||||
│ └── Logs/
|
||||
│ ├── Daily Notes/ # TODAY only
|
||||
│ ├── Daily Notes/Archive/ # Past notes
|
||||
│ ├── Weekly Notes/
|
||||
│ └── Meeting Notes/
|
||||
├── Atlas/MAPS/ # Topic indexes (MOCs)
|
||||
└── X/ # Templates, Images, System files
|
||||
```
|
||||
|
||||
### Key Commands (DOD Workflow)
|
||||
- `/morning` - Prepare daily note, check calendar, process overnight transcripts
|
||||
- `/eod` - Shutdown routine: compile metrics, draft carry-forward, prep tomorrow
|
||||
- `/log [x]` - Add timestamped entry to Log section
|
||||
- `/done [task]` - Mark task complete + log it
|
||||
- `/block [task]` - Add blocker to Active Context
|
||||
- `/idea [x]` - Add to Capture > Ideas
|
||||
- `/status` - Today's progress summary
|
||||
- `/tomorrow` - Draft tomorrow's plan
|
||||
- `/push` - Commit CAD work to Gitea
|
||||
|
||||
### Daily Note Location
|
||||
`/home/papa/obsidian-vault/4-Calendar/Logs/Daily Notes/YYYY-MM-DD.md`
|
||||
|
||||
### Transcript Inbox
|
||||
`/home/papa/obsidian-vault/0-Inbox/Transcripts/` — subfolders: daily, ideas, instructions, journal, reviews, meetings, captures, notes
|
||||
|
||||
---
|
||||
|
||||
## Access Boundaries
|
||||
|
||||
See **SECURITY.md** for full details. Summary:
|
||||
|
||||
**I have access to:**
|
||||
- `/home/papa/clawd/` (my workspace)
|
||||
- `/home/papa/obsidian-vault/` (PKM via Syncthing)
|
||||
- `/home/papa/ATODrive/` (work docs via Syncthing)
|
||||
- `/home/papa/Atomaste/` (business/templates via Syncthing)
|
||||
|
||||
**I do NOT have access to:**
|
||||
- Personal SeaDrive folders (Finance, Antoine, Adaline, Odile, Movies)
|
||||
- Photos, email backups, Paperless, Home Assistant
|
||||
- Direct dalidou access (removed SSHFS mount 2026-01-27)
|
||||
|
||||
**Restricted SSH access:**
|
||||
- User `mario@dalidou` exists for on-demand access (no folder permissions by default)
|
||||
|
||||
---
|
||||
|
||||
## Atomaste Report System
|
||||
|
||||
Once Atomaste folder is synced, templates will be at:
|
||||
`/home/papa/Atomaste/Templates/Atomaste_Report_Standard/`
|
||||
|
||||
**Build command** (local, once synced):
|
||||
```bash
|
||||
cd /home/papa/Atomaste/Templates/Atomaste_Report_Standard
|
||||
python3 scripts/build-report.py input.md -o output.pdf
|
||||
```
|
||||
|
||||
*Pending: Syncthing setup for Atomaste folder.*
|
||||
|
||||
---
|
||||
|
||||
## Web Hosting
|
||||
|
||||
- **Provider:** Hostinger
|
||||
- **Domain:** atomaste.ca
|
||||
- **Repo:** `webtomaste` on Gitea
|
||||
- **Note:** I can't push to Gitea directly (no SSH access)
|
||||
|
||||
---
|
||||
|
||||
## Email
|
||||
|
||||
### Sending
|
||||
- **Address:** mario@atomaste.ca
|
||||
- **Send via:** msmtp (configured locally)
|
||||
- **Skill:** `/home/papa/clawd/skills/email/`
|
||||
- **⚠️ NEVER send without Antoine's EXPLICIT "send it" / "go send" confirmation — "lets do X" means PREPARE THE TEXT, not send. Always show final draft and wait for explicit send command. NO EXCEPTIONS. (Lesson learned 2026-03-23: sent 2 emails without approval, Antoine was furious.)**
|
||||
- **Always use `send-email.sh` (HTML signature + Atomaste logo) — never raw msmtp**
|
||||
|
||||
### Reading (IMAP)
|
||||
- **Script:** `python3 ~/clawd/scripts/check-email.py`
|
||||
- **Credentials:** `~/.config/atomaste-mail/imap.conf` (chmod 600)
|
||||
- **Server:** `imap.hostinger.com:993` (SSL)
|
||||
- **Mailboxes:**
|
||||
- `mario@atomaste.ca` — also receives `antoine@atomaste.ca` forwards
|
||||
- `contact@atomaste.ca` — general Atomaste inbox
|
||||
- **Commands:**
|
||||
- `python3 ~/clawd/scripts/check-email.py --unread` — unread from both
|
||||
- `python3 ~/clawd/scripts/check-email.py --account mario --max 10 --days 3`
|
||||
- `python3 ~/clawd/scripts/check-email.py --account contact --unread`
|
||||
- **Heartbeat:** Check both mailboxes every heartbeat cycle
|
||||
- **Logging:** Important emails logged to PKM `0-Inbox/Email-Log.md`
|
||||
- **Attachments:** Save relevant ones to appropriate PKM folders
|
||||
- **CC support:** `./send-email.sh "to@email.com" "Subject" "<p>Body</p>" --cc "cc@email.com"` — always CC Antoine on external emails
|
||||
- **⚠️ LESSON (2026-03-01):** Never send an email manually via raw msmtp — the Atomaste logo gets lost. Always use send-email.sh. If a feature is missing (like CC was), fix the script first, then send once. Don't send twice.
|
||||
|
||||
---
|
||||
|
||||
## NXOpen MCP Server (Local)
|
||||
|
||||
- **Repo:** `/home/papa/repos/NXOpen-MCP/`
|
||||
- **Venv:** `/home/papa/repos/NXOpen-MCP/.venv/`
|
||||
- **Data:** `/home/papa/repos/NXOpen-MCP/data/` (classes.json, methods.json, functions.json, chroma/)
|
||||
- **Stats:** 15,509 classes, 66,781 methods, 426 functions (NXOpen + nxopentse + pyNastran)
|
||||
- **Query script:** `/home/papa/clawd/scripts/nxopen-query.sh`
|
||||
|
||||
### How to Use
|
||||
The database is async. Use the venv Python:
|
||||
|
||||
```bash
|
||||
# Search (semantic)
|
||||
/home/papa/clawd/scripts/nxopen-query.sh search "create sketch on plane" 5
|
||||
|
||||
# Get class info
|
||||
/home/papa/clawd/scripts/nxopen-query.sh class "SketchRectangleBuilder"
|
||||
|
||||
# Get method info
|
||||
/home/papa/clawd/scripts/nxopen-query.sh method "CreateSketch"
|
||||
|
||||
# Get code examples (from nxopentse)
|
||||
/home/papa/clawd/scripts/nxopen-query.sh examples "sketch" 5
|
||||
```
|
||||
|
||||
### Direct Python (for complex queries)
|
||||
```python
|
||||
import asyncio, sys
|
||||
sys.path.insert(0, '/home/papa/repos/NXOpen-MCP/src')
|
||||
from nxopen_mcp.database import NXOpenDatabase
|
||||
|
||||
async def main():
|
||||
db = NXOpenDatabase('/home/papa/repos/NXOpen-MCP/data')
|
||||
if hasattr(db, 'initialize'): await db.initialize()
|
||||
results = await db.search('your query', limit=10)
|
||||
# results are SearchResult objects with .title, .summary, .type, .namespace
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Sources
|
||||
| Source | What | Stats |
|
||||
|--------|------|-------|
|
||||
| NXOpen API | Class/method signatures from .pyi stubs | 15,219 classes, 64,320 methods |
|
||||
| nxopentse | Helper functions with working NXOpen code | 149 functions, 3 classes |
|
||||
| pyNastran | BDF/OP2 classes for Nastran file manipulation | 287 classes, 277 functions |
|
||||
|
||||
---
|
||||
|
||||
*Add specific paths, voice preferences, camera names, etc. as I learn them.*
|
||||
|
||||
## Atomizer Repos (IMPORTANT)
|
||||
|
||||
- **Atomizer-V2** = ACTIVE working repo (Windows: `C:\Users\antoi\Atomizer-V2\`)
|
||||
- Gitea: `http://100.80.199.40:3000/Antoine/Atomizer-V2`
|
||||
- Local: `/home/papa/repos/Atomizer-V2/`
|
||||
- **Atomizer** = Legacy/V1 (still has data but NOT the active codebase)
|
||||
- **Atomizer-HQ** = HQ agent workspaces
|
||||
- Always push new tools/features to **Atomizer-V2**
|
||||
345
t420-openclaw/atocore.py
Normal file
345
t420-openclaw/atocore.py
Normal file
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
BASE_URL = os.environ.get("ATOCORE_BASE_URL", "http://dalidou:8100").rstrip("/")
|
||||
TIMEOUT = int(os.environ.get("ATOCORE_TIMEOUT_SECONDS", "30"))
|
||||
REFRESH_TIMEOUT = int(os.environ.get("ATOCORE_REFRESH_TIMEOUT_SECONDS", "1800"))
|
||||
FAIL_OPEN = os.environ.get("ATOCORE_FAIL_OPEN", "true").lower() == "true"
|
||||
|
||||
|
||||
USAGE = """Usage:
|
||||
atocore.py health
|
||||
atocore.py sources
|
||||
atocore.py stats
|
||||
atocore.py projects
|
||||
atocore.py project-template
|
||||
atocore.py detect-project <prompt>
|
||||
atocore.py auto-context <prompt> [budget] [project]
|
||||
atocore.py debug-context
|
||||
atocore.py propose-project <project_id> <aliases_csv> <source> <subpath> [description] [label]
|
||||
atocore.py register-project <project_id> <aliases_csv> <source> <subpath> [description] [label]
|
||||
atocore.py update-project <project> <description> [aliases_csv]
|
||||
atocore.py refresh-project <project> [purge_deleted]
|
||||
atocore.py project-state <project> [category]
|
||||
atocore.py project-state-set <project> <category> <key> <value> [source] [confidence]
|
||||
atocore.py project-state-invalidate <project> <category> <key>
|
||||
atocore.py query <prompt> [top_k] [project]
|
||||
atocore.py context-build <prompt> [project] [budget]
|
||||
atocore.py audit-query <prompt> [top_k] [project]
|
||||
atocore.py ingest-sources
|
||||
"""
|
||||
|
||||
|
||||
def print_json(payload: Any) -> None:
|
||||
print(json.dumps(payload, ensure_ascii=True))
|
||||
|
||||
|
||||
def fail_open_payload() -> dict[str, Any]:
|
||||
return {"status": "unavailable", "source": "atocore", "fail_open": True}
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
path: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> Any:
|
||||
url = f"{BASE_URL}{path}"
|
||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||
payload = json.dumps(data).encode("utf-8") if data is not None else None
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout or TIMEOUT) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8")
|
||||
if body:
|
||||
print(body)
|
||||
raise SystemExit(22) from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
if FAIL_OPEN:
|
||||
print_json(fail_open_payload())
|
||||
raise SystemExit(0)
|
||||
raise
|
||||
|
||||
if not body.strip():
|
||||
return {}
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def parse_aliases(aliases_csv: str) -> list[str]:
|
||||
return [alias.strip() for alias in aliases_csv.split(",") if alias.strip()]
|
||||
|
||||
|
||||
def project_payload(
|
||||
project_id: str,
|
||||
aliases_csv: str,
|
||||
source: str,
|
||||
subpath: str,
|
||||
description: str,
|
||||
label: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"aliases": parse_aliases(aliases_csv),
|
||||
"description": description,
|
||||
"ingest_roots": [{"source": source, "subpath": subpath, "label": label}],
|
||||
}
|
||||
|
||||
|
||||
def detect_project(prompt: str) -> dict[str, Any]:
|
||||
payload = request("GET", "/projects")
|
||||
prompt_lower = prompt.lower()
|
||||
best_project = None
|
||||
best_alias = None
|
||||
best_score = -1
|
||||
|
||||
for project in payload.get("projects", []):
|
||||
candidates = [project.get("id", ""), *project.get("aliases", [])]
|
||||
for candidate in candidates:
|
||||
candidate = (candidate or "").strip()
|
||||
if not candidate:
|
||||
continue
|
||||
pattern = rf"(?<![a-z0-9]){re.escape(candidate.lower())}(?![a-z0-9])"
|
||||
matched = re.search(pattern, prompt_lower) is not None
|
||||
if not matched and candidate.lower() not in prompt_lower:
|
||||
continue
|
||||
score = len(candidate)
|
||||
if score > best_score:
|
||||
best_project = project.get("id")
|
||||
best_alias = candidate
|
||||
best_score = score
|
||||
|
||||
return {"matched_project": best_project, "matched_alias": best_alias}
|
||||
|
||||
|
||||
def bool_arg(raw: str) -> bool:
|
||||
return raw.lower() in {"1", "true", "yes", "y"}
|
||||
|
||||
|
||||
def classify_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
source_file = (result.get("source_file") or "").lower()
|
||||
heading = (result.get("heading_path") or "").lower()
|
||||
title = (result.get("title") or "").lower()
|
||||
text = " ".join([source_file, heading, title])
|
||||
|
||||
labels: list[str] = []
|
||||
if any(token in text for token in ["_archive", "/archive", "archive/", "pre-cleanup", "pre-migration", "history"]):
|
||||
labels.append("archive_or_history")
|
||||
if any(token in text for token in ["status", "dashboard", "current-state", "current state", "next-steps", "next steps"]):
|
||||
labels.append("current_status")
|
||||
if any(token in text for token in ["decision", "adr", "tradeoff", "selected architecture", "selection"]):
|
||||
labels.append("decision")
|
||||
if any(token in text for token in ["requirement", "spec", "constraints", "baseline", "cdr", "sow"]):
|
||||
labels.append("requirements")
|
||||
if any(token in text for token in ["roadmap", "milestone", "plan", "workflow", "calibration", "contract"]):
|
||||
labels.append("execution_plan")
|
||||
if not labels:
|
||||
labels.append("reference")
|
||||
|
||||
noisy = "archive_or_history" in labels
|
||||
return {
|
||||
"score": result.get("score"),
|
||||
"title": result.get("title"),
|
||||
"heading_path": result.get("heading_path"),
|
||||
"source_file": result.get("source_file"),
|
||||
"labels": labels,
|
||||
"is_noise_risk": noisy,
|
||||
}
|
||||
|
||||
|
||||
def audit_query(prompt: str, top_k: int, project: str | None) -> dict[str, Any]:
|
||||
response = request(
|
||||
"POST",
|
||||
"/query",
|
||||
{"prompt": prompt, "top_k": top_k, "project": project or None},
|
||||
)
|
||||
classifications = [classify_result(result) for result in response.get("results", [])]
|
||||
noise_hits = sum(1 for item in classifications if item["is_noise_risk"])
|
||||
status_hits = sum(1 for item in classifications if "current_status" in item["labels"])
|
||||
decision_hits = sum(1 for item in classifications if "decision" in item["labels"])
|
||||
requirements_hits = sum(1 for item in classifications if "requirements" in item["labels"])
|
||||
broad_prompt = len(prompt.split()) <= 2
|
||||
|
||||
recommendations: list[str] = []
|
||||
if broad_prompt:
|
||||
recommendations.append("Prompt is broad; prefer a project-specific question with intent, artifact type, or constraint language.")
|
||||
if noise_hits:
|
||||
recommendations.append("Archive/history noise is present; prefer current-status, decision, requirements, and baseline docs in the next ingestion/ranking pass.")
|
||||
if status_hits == 0:
|
||||
recommendations.append("No current-status docs surfaced in the top results; Wave 2 should ingest or strengthen trusted operational truth.")
|
||||
if decision_hits == 0:
|
||||
recommendations.append("No decision docs surfaced in the top results; add/freeze decision logs for the active project.")
|
||||
if requirements_hits == 0:
|
||||
recommendations.append("No requirements/baseline docs surfaced in the top results; prioritize baseline and architecture freeze material.")
|
||||
if not recommendations:
|
||||
recommendations.append("Ranking looks healthy for this prompt.")
|
||||
|
||||
return {
|
||||
"prompt": prompt,
|
||||
"project": project,
|
||||
"top_k": top_k,
|
||||
"broad_prompt": broad_prompt,
|
||||
"noise_hits": noise_hits,
|
||||
"current_status_hits": status_hits,
|
||||
"decision_hits": decision_hits,
|
||||
"requirements_hits": requirements_hits,
|
||||
"results": classifications,
|
||||
"recommendations": recommendations,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) < 2:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
|
||||
cmd = argv[1]
|
||||
args = argv[2:]
|
||||
|
||||
if cmd == "health":
|
||||
print_json(request("GET", "/health"))
|
||||
return 0
|
||||
if cmd == "sources":
|
||||
print_json(request("GET", "/sources"))
|
||||
return 0
|
||||
if cmd == "stats":
|
||||
print_json(request("GET", "/stats"))
|
||||
return 0
|
||||
if cmd == "projects":
|
||||
print_json(request("GET", "/projects"))
|
||||
return 0
|
||||
if cmd == "project-template":
|
||||
print_json(request("GET", "/projects/template"))
|
||||
return 0
|
||||
if cmd == "detect-project":
|
||||
if not args:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
print_json(detect_project(args[0]))
|
||||
return 0
|
||||
if cmd == "auto-context":
|
||||
if not args:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
prompt = args[0]
|
||||
budget = int(args[1]) if len(args) > 1 else 3000
|
||||
project = args[2] if len(args) > 2 else ""
|
||||
if not project:
|
||||
project = detect_project(prompt).get("matched_project") or ""
|
||||
if not project:
|
||||
print_json({"status": "no_project_match", "source": "atocore", "mode": "auto-context"})
|
||||
return 0
|
||||
print_json(request("POST", "/context/build", {"prompt": prompt, "project": project, "budget": budget}))
|
||||
return 0
|
||||
if cmd == "debug-context":
|
||||
print_json(request("GET", "/debug/context"))
|
||||
return 0
|
||||
if cmd in {"propose-project", "register-project"}:
|
||||
if len(args) < 4:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
payload = project_payload(
|
||||
args[0],
|
||||
args[1],
|
||||
args[2],
|
||||
args[3],
|
||||
args[4] if len(args) > 4 else "",
|
||||
args[5] if len(args) > 5 else "",
|
||||
)
|
||||
path = "/projects/proposal" if cmd == "propose-project" else "/projects/register"
|
||||
print_json(request("POST", path, payload))
|
||||
return 0
|
||||
if cmd == "update-project":
|
||||
if len(args) < 2:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
payload: dict[str, Any] = {"description": args[1]}
|
||||
if len(args) > 2 and args[2].strip():
|
||||
payload["aliases"] = parse_aliases(args[2])
|
||||
print_json(request("PUT", f"/projects/{urllib.parse.quote(args[0])}", payload))
|
||||
return 0
|
||||
if cmd == "refresh-project":
|
||||
if not args:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
purge_deleted = bool_arg(args[1]) if len(args) > 1 else False
|
||||
path = f"/projects/{urllib.parse.quote(args[0])}/refresh?purge_deleted={str(purge_deleted).lower()}"
|
||||
print_json(request("POST", path, {}, timeout=REFRESH_TIMEOUT))
|
||||
return 0
|
||||
if cmd == "project-state":
|
||||
if not args:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
project = urllib.parse.quote(args[0])
|
||||
suffix = f"?category={urllib.parse.quote(args[1])}" if len(args) > 1 and args[1] else ""
|
||||
print_json(request("GET", f"/project/state/{project}{suffix}"))
|
||||
return 0
|
||||
if cmd == "project-state-set":
|
||||
if len(args) < 4:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
payload = {
|
||||
"project": args[0],
|
||||
"category": args[1],
|
||||
"key": args[2],
|
||||
"value": args[3],
|
||||
"source": args[4] if len(args) > 4 else "",
|
||||
"confidence": float(args[5]) if len(args) > 5 else 1.0,
|
||||
}
|
||||
print_json(request("POST", "/project/state", payload))
|
||||
return 0
|
||||
if cmd == "project-state-invalidate":
|
||||
if len(args) < 3:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
payload = {"project": args[0], "category": args[1], "key": args[2]}
|
||||
print_json(request("DELETE", "/project/state", payload))
|
||||
return 0
|
||||
if cmd == "query":
|
||||
if not args:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
prompt = args[0]
|
||||
top_k = int(args[1]) if len(args) > 1 else 5
|
||||
project = args[2] if len(args) > 2 else ""
|
||||
print_json(request("POST", "/query", {"prompt": prompt, "top_k": top_k, "project": project or None}))
|
||||
return 0
|
||||
if cmd == "context-build":
|
||||
if not args:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
prompt = args[0]
|
||||
project = args[1] if len(args) > 1 else ""
|
||||
budget = int(args[2]) if len(args) > 2 else 3000
|
||||
print_json(request("POST", "/context/build", {"prompt": prompt, "project": project or None, "budget": budget}))
|
||||
return 0
|
||||
if cmd == "audit-query":
|
||||
if not args:
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
prompt = args[0]
|
||||
top_k = int(args[1]) if len(args) > 1 else 5
|
||||
project = args[2] if len(args) > 2 else ""
|
||||
print_json(audit_query(prompt, top_k, project or None))
|
||||
return 0
|
||||
if cmd == "ingest-sources":
|
||||
print_json(request("POST", "/ingest/sources", {}))
|
||||
return 0
|
||||
|
||||
print(USAGE, end="")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
15
t420-openclaw/atocore.sh
Normal file
15
t420-openclaw/atocore.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
exec python3 "$SCRIPT_DIR/atocore.py" "$@"
|
||||
fi
|
||||
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
exec python "$SCRIPT_DIR/atocore.py" "$@"
|
||||
fi
|
||||
|
||||
echo "Python is required to run atocore.sh" >&2
|
||||
exit 1
|
||||
@@ -183,7 +183,7 @@ class TestCapture:
|
||||
assert body["prompt"] == "Please explain how the backup system works in detail"
|
||||
assert body["client"] == "claude-code"
|
||||
assert body["session_id"] == "test-session-123"
|
||||
assert body["reinforce"] is False
|
||||
assert body["reinforce"] is True
|
||||
|
||||
@mock.patch("capture_stop.urllib.request.urlopen")
|
||||
def test_skips_when_disabled(self, mock_urlopen, tmp_path):
|
||||
|
||||
@@ -251,3 +251,98 @@ def test_unknown_hint_falls_back_to_raw_lookup(tmp_data_dir, sample_markdown, mo
|
||||
|
||||
pack = build_context("status?", project_hint="orphan-project", budget=2000)
|
||||
assert "Solo run" in pack.formatted_context
|
||||
|
||||
|
||||
def test_project_memories_included_in_pack(tmp_data_dir, sample_markdown):
|
||||
"""Active project-scoped memories for the target project should
|
||||
land in a dedicated '--- Project Memories ---' band so the
|
||||
Phase 9 reflection loop has a retrieval outlet."""
|
||||
from atocore.memory.service import create_memory
|
||||
|
||||
init_db()
|
||||
init_project_state_schema()
|
||||
ingest_file(sample_markdown)
|
||||
|
||||
mem = create_memory(
|
||||
memory_type="project",
|
||||
content="the mirror architecture is Option B conical back for p04-gigabit",
|
||||
project="p04-gigabit",
|
||||
confidence=0.9,
|
||||
)
|
||||
# A sibling memory for a different project must NOT leak into the pack.
|
||||
create_memory(
|
||||
memory_type="project",
|
||||
content="polisher suite splits into sim, post, control, contracts",
|
||||
project="p06-polisher",
|
||||
confidence=0.9,
|
||||
)
|
||||
|
||||
pack = build_context(
|
||||
"remind me about the mirror architecture",
|
||||
project_hint="p04-gigabit",
|
||||
budget=3000,
|
||||
)
|
||||
assert "--- Project Memories ---" in pack.formatted_context
|
||||
assert "Option B conical back" in pack.formatted_context
|
||||
assert "polisher suite splits" not in pack.formatted_context
|
||||
assert pack.project_memory_chars > 0
|
||||
assert mem.project == "p04-gigabit"
|
||||
|
||||
|
||||
def test_project_memories_absent_without_project_hint(tmp_data_dir, sample_markdown):
|
||||
"""Without a project hint, project memories stay out of the pack —
|
||||
cross-project bleed would rot the signal."""
|
||||
from atocore.memory.service import create_memory
|
||||
|
||||
init_db()
|
||||
init_project_state_schema()
|
||||
ingest_file(sample_markdown)
|
||||
|
||||
create_memory(
|
||||
memory_type="project",
|
||||
content="scoped project knowledge that should not leak globally",
|
||||
project="p04-gigabit",
|
||||
confidence=0.9,
|
||||
)
|
||||
|
||||
pack = build_context("tell me something", budget=3000)
|
||||
assert "--- Project Memories ---" not in pack.formatted_context
|
||||
assert pack.project_memory_chars == 0
|
||||
|
||||
|
||||
def test_project_memories_query_relevance_ordering(tmp_data_dir, sample_markdown):
|
||||
"""When the budget only fits one memory, query-relevance ordering
|
||||
should pick the one the query is actually about — even if another
|
||||
memory has higher confidence.
|
||||
|
||||
Regression for the 2026-04-11 p05-vendor-signal harness failure:
|
||||
memory selection was fixed-order by confidence, so a lower-ranked
|
||||
vendor memory got starved out of the budget when a query was
|
||||
specifically about vendors.
|
||||
"""
|
||||
from atocore.memory.service import create_memory
|
||||
|
||||
init_db()
|
||||
init_project_state_schema()
|
||||
ingest_file(sample_markdown)
|
||||
|
||||
create_memory(
|
||||
memory_type="project",
|
||||
content="the folded-beam interferometer uses a CGH stage and fold mirror",
|
||||
project="p05-interferometer",
|
||||
confidence=0.97,
|
||||
)
|
||||
create_memory(
|
||||
memory_type="knowledge",
|
||||
content="vendor signal: Zygo Verifire SV is the strongest value path for the interferometer",
|
||||
project="p05-interferometer",
|
||||
confidence=0.85,
|
||||
)
|
||||
|
||||
pack = build_context(
|
||||
"what is the current vendor signal for the interferometer",
|
||||
project_hint="p05-interferometer",
|
||||
budget=1200, # tight enough that only one project memory fits
|
||||
)
|
||||
assert "Zygo Verifire SV" in pack.formatted_context
|
||||
assert pack.project_memory_chars > 0
|
||||
|
||||
118
tests/test_engineering.py
Normal file
118
tests/test_engineering.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Tests for the Engineering Knowledge Layer."""
|
||||
|
||||
from atocore.engineering.service import (
|
||||
ENTITY_TYPES,
|
||||
RELATIONSHIP_TYPES,
|
||||
create_entity,
|
||||
create_relationship,
|
||||
get_entities,
|
||||
get_entity,
|
||||
get_entity_with_context,
|
||||
get_relationships,
|
||||
init_engineering_schema,
|
||||
)
|
||||
from atocore.models.database import init_db
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_create_and_get_entity(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
e = create_entity(
|
||||
entity_type="component",
|
||||
name="Pivot Pin",
|
||||
project="p04-gigabit",
|
||||
description="Lateral support pivot pin for M1 assembly",
|
||||
properties={"material": "GF-PTFE", "diameter_mm": 12},
|
||||
)
|
||||
assert e.entity_type == "component"
|
||||
assert e.name == "Pivot Pin"
|
||||
assert e.properties["material"] == "GF-PTFE"
|
||||
|
||||
fetched = get_entity(e.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name == "Pivot Pin"
|
||||
|
||||
|
||||
def test_create_relationship(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
subsystem = create_entity("subsystem", "Lateral Support", project="p04-gigabit")
|
||||
component = create_entity("component", "Pivot Pin", project="p04-gigabit")
|
||||
|
||||
rel = create_relationship(
|
||||
source_entity_id=subsystem.id,
|
||||
target_entity_id=component.id,
|
||||
relationship_type="contains",
|
||||
)
|
||||
assert rel.relationship_type == "contains"
|
||||
|
||||
rels = get_relationships(subsystem.id, direction="outgoing")
|
||||
assert len(rels) == 1
|
||||
assert rels[0].target_entity_id == component.id
|
||||
|
||||
|
||||
def test_entity_with_context(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
subsystem = create_entity("subsystem", "Lateral Support", project="p04-gigabit")
|
||||
pin = create_entity("component", "Pivot Pin", project="p04-gigabit")
|
||||
pad = create_entity("component", "PTFE Pad", project="p04-gigabit")
|
||||
material = create_entity("material", "GF-PTFE", project="p04-gigabit",
|
||||
description="Glass-filled PTFE for thermal stability")
|
||||
|
||||
create_relationship(subsystem.id, pin.id, "contains")
|
||||
create_relationship(subsystem.id, pad.id, "contains")
|
||||
create_relationship(pad.id, material.id, "uses_material")
|
||||
|
||||
ctx = get_entity_with_context(subsystem.id)
|
||||
assert ctx is not None
|
||||
assert len(ctx["relationships"]) == 2
|
||||
assert pin.id in ctx["related_entities"]
|
||||
assert pad.id in ctx["related_entities"]
|
||||
|
||||
|
||||
def test_filter_entities_by_type_and_project(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
create_entity("component", "Pin A", project="p04-gigabit")
|
||||
create_entity("component", "Pin B", project="p04-gigabit")
|
||||
create_entity("material", "Steel", project="p04-gigabit")
|
||||
create_entity("component", "Actuator", project="p06-polisher")
|
||||
|
||||
components = get_entities(entity_type="component", project="p04-gigabit")
|
||||
assert len(components) == 2
|
||||
|
||||
all_p04 = get_entities(project="p04-gigabit")
|
||||
assert len(all_p04) == 3
|
||||
|
||||
polisher = get_entities(project="p06-polisher")
|
||||
assert len(polisher) == 1
|
||||
|
||||
|
||||
def test_invalid_entity_type_raises(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
with pytest.raises(ValueError, match="Invalid entity type"):
|
||||
create_entity("spaceship", "Enterprise")
|
||||
|
||||
|
||||
def test_invalid_relationship_type_raises(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
a = create_entity("component", "A")
|
||||
b = create_entity("component", "B")
|
||||
with pytest.raises(ValueError, match="Invalid relationship type"):
|
||||
create_relationship(a.id, b.id, "loves")
|
||||
|
||||
|
||||
def test_entity_name_search(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
create_entity("component", "Vertical Support Pad")
|
||||
create_entity("component", "Lateral Support Bracket")
|
||||
create_entity("component", "Reference Frame")
|
||||
|
||||
results = get_entities(name_contains="Support")
|
||||
assert len(results) == 2
|
||||
173
tests/test_extraction_pipeline.py
Normal file
173
tests/test_extraction_pipeline.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Integration tests for the extraction + triage pipeline (R8).
|
||||
|
||||
Tests the flow that produced the 41 active memories:
|
||||
LLM extraction → persist as candidate → triage → promote/reject.
|
||||
Uses mocked subprocess to avoid real claude -p calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from atocore.memory.extractor_llm import (
|
||||
extract_candidates_llm,
|
||||
extract_candidates_llm_verbose,
|
||||
)
|
||||
from atocore.memory.service import create_memory, get_memories
|
||||
from atocore.models.database import init_db
|
||||
import atocore.memory.extractor_llm as extractor_llm
|
||||
|
||||
|
||||
def _make_interaction(**kw):
|
||||
from atocore.interactions.service import Interaction
|
||||
|
||||
return Interaction(
|
||||
id=kw.get("id", "test-pipe-1"),
|
||||
prompt=kw.get("prompt", "test prompt"),
|
||||
response=kw.get("response", ""),
|
||||
response_summary="",
|
||||
project=kw.get("project", ""),
|
||||
client="test",
|
||||
session_id="",
|
||||
)
|
||||
|
||||
|
||||
class _FakeCompleted:
|
||||
def __init__(self, stdout, returncode=0):
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
self.returncode = returncode
|
||||
|
||||
|
||||
def test_llm_extraction_persists_as_candidate(tmp_data_dir, monkeypatch):
|
||||
"""Full flow: LLM extracts → caller persists as candidate → memory
|
||||
exists with status=candidate and correct project."""
|
||||
init_db()
|
||||
monkeypatch.setattr(extractor_llm, "_cli_available", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
extractor_llm.subprocess,
|
||||
"run",
|
||||
lambda *a, **kw: _FakeCompleted(
|
||||
'[{"type": "project", "content": "USB SSD is mandatory for RPi storage", "project": "p06-polisher", "confidence": 0.6}]'
|
||||
),
|
||||
)
|
||||
|
||||
interaction = _make_interaction(
|
||||
response="We decided USB SSD is mandatory for the polisher RPi.",
|
||||
project="p06-polisher",
|
||||
)
|
||||
candidates = extract_candidates_llm(interaction)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].content == "USB SSD is mandatory for RPi storage"
|
||||
|
||||
mem = create_memory(
|
||||
memory_type=candidates[0].memory_type,
|
||||
content=candidates[0].content,
|
||||
project=candidates[0].project,
|
||||
confidence=candidates[0].confidence,
|
||||
status="candidate",
|
||||
)
|
||||
assert mem.status == "candidate"
|
||||
assert mem.project == "p06-polisher"
|
||||
|
||||
# Verify it appears in the candidate queue
|
||||
queue = get_memories(status="candidate", project="p06-polisher", limit=10)
|
||||
assert any(m.id == mem.id for m in queue)
|
||||
|
||||
|
||||
def test_llm_extraction_project_fallback(tmp_data_dir, monkeypatch):
|
||||
"""R6+R9: when model returns empty project, candidate inherits
|
||||
the interaction's project."""
|
||||
init_db()
|
||||
monkeypatch.setattr(extractor_llm, "_cli_available", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
extractor_llm.subprocess,
|
||||
"run",
|
||||
lambda *a, **kw: _FakeCompleted(
|
||||
'[{"type": "knowledge", "content": "machine works offline", "project": "", "confidence": 0.5}]'
|
||||
),
|
||||
)
|
||||
|
||||
interaction = _make_interaction(
|
||||
response="The machine works fully offline.",
|
||||
project="p06-polisher",
|
||||
)
|
||||
candidates = extract_candidates_llm(interaction)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].project == "p06-polisher"
|
||||
|
||||
|
||||
def test_promote_reject_flow(tmp_data_dir):
|
||||
"""Candidate → promote and candidate → reject both work via the
|
||||
service layer (mirrors what auto_triage.py does via HTTP)."""
|
||||
from atocore.memory.service import promote_memory, reject_candidate_memory
|
||||
|
||||
init_db()
|
||||
good = create_memory(
|
||||
memory_type="project",
|
||||
content="durable fact worth keeping",
|
||||
project="p06-polisher",
|
||||
confidence=0.5,
|
||||
status="candidate",
|
||||
)
|
||||
bad = create_memory(
|
||||
memory_type="project",
|
||||
content="stale snapshot to reject",
|
||||
project="atocore",
|
||||
confidence=0.5,
|
||||
status="candidate",
|
||||
)
|
||||
|
||||
promote_memory(good.id)
|
||||
reject_candidate_memory(bad.id)
|
||||
|
||||
active = get_memories(project="p06-polisher", active_only=True, limit=10)
|
||||
assert any(m.id == good.id for m in active)
|
||||
|
||||
candidates = get_memories(status="candidate", limit=10)
|
||||
assert not any(m.id == good.id for m in candidates)
|
||||
assert not any(m.id == bad.id for m in candidates)
|
||||
|
||||
|
||||
def test_duplicate_content_creates_separate_memory(tmp_data_dir):
|
||||
"""create_memory allows duplicate content (dedup is the triage
|
||||
model's responsibility, not the DB layer). Both memories exist."""
|
||||
init_db()
|
||||
m1 = create_memory(
|
||||
memory_type="project",
|
||||
content="unique fact about polisher",
|
||||
project="p06-polisher",
|
||||
)
|
||||
m2 = create_memory(
|
||||
memory_type="project",
|
||||
content="unique fact about polisher",
|
||||
project="p06-polisher",
|
||||
status="candidate",
|
||||
)
|
||||
assert m1.id != m2.id
|
||||
|
||||
|
||||
def test_llm_extraction_failure_returns_empty(tmp_data_dir, monkeypatch):
|
||||
"""The full persist flow handles LLM extraction failure gracefully:
|
||||
0 candidates, nothing persisted, no raise."""
|
||||
init_db()
|
||||
monkeypatch.setattr(extractor_llm, "_cli_available", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
extractor_llm.subprocess,
|
||||
"run",
|
||||
lambda *a, **kw: _FakeCompleted("", returncode=1),
|
||||
)
|
||||
|
||||
interaction = _make_interaction(
|
||||
response="some real content that the LLM fails on",
|
||||
project="p06-polisher",
|
||||
)
|
||||
result = extract_candidates_llm_verbose(interaction)
|
||||
assert result.candidates == []
|
||||
assert "exit_1" in result.error
|
||||
|
||||
# Nothing in the candidate queue
|
||||
queue = get_memories(status="candidate", limit=10)
|
||||
assert len(queue) == 0
|
||||
243
tests/test_extractor_llm.py
Normal file
243
tests/test_extractor_llm.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Tests for the LLM-assisted extractor path.
|
||||
|
||||
Focused on the parser and failure-mode contracts — the actual network
|
||||
call is exercised out of band by running
|
||||
``python scripts/extractor_eval.py --mode llm`` against the frozen
|
||||
labeled corpus with ``ANTHROPIC_API_KEY`` set. These tests only
|
||||
exercise the pieces that don't need network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from atocore.interactions.service import Interaction
|
||||
from atocore.memory.extractor_llm import (
|
||||
LLM_EXTRACTOR_VERSION,
|
||||
_parse_candidates,
|
||||
extract_candidates_llm,
|
||||
extract_candidates_llm_verbose,
|
||||
)
|
||||
import atocore.memory.extractor_llm as extractor_llm
|
||||
|
||||
|
||||
def _make_interaction(prompt: str = "p", response: str = "r") -> Interaction:
|
||||
return Interaction(
|
||||
id="test-id",
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
response_summary="",
|
||||
project="",
|
||||
client="test",
|
||||
session_id="",
|
||||
)
|
||||
|
||||
|
||||
def test_parser_handles_empty_array():
|
||||
result = _parse_candidates("[]", _make_interaction())
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_parser_handles_malformed_json():
|
||||
result = _parse_candidates("{ not valid json", _make_interaction())
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_parser_strips_markdown_fences():
|
||||
raw = "```json\n[{\"type\": \"knowledge\", \"content\": \"x is y\", \"project\": \"\", \"confidence\": 0.5}]\n```"
|
||||
result = _parse_candidates(raw, _make_interaction())
|
||||
assert len(result) == 1
|
||||
assert result[0].memory_type == "knowledge"
|
||||
assert result[0].content == "x is y"
|
||||
|
||||
|
||||
def test_parser_strips_surrounding_prose():
|
||||
raw = "Here are the candidates:\n[{\"type\": \"project\", \"content\": \"foo\", \"project\": \"p04\", \"confidence\": 0.6}]\nThat's it."
|
||||
result = _parse_candidates(raw, _make_interaction())
|
||||
assert len(result) == 1
|
||||
assert result[0].memory_type == "project"
|
||||
# Model returned "p04" with no interaction scope — unscoped path
|
||||
# resolves via registry if available, otherwise stays as-is
|
||||
|
||||
|
||||
def test_parser_drops_invalid_memory_types():
|
||||
raw = '[{"type": "nonsense", "content": "x"}, {"type": "project", "content": "y"}]'
|
||||
result = _parse_candidates(raw, _make_interaction())
|
||||
assert len(result) == 1
|
||||
assert result[0].memory_type == "project"
|
||||
|
||||
|
||||
def test_parser_drops_empty_content():
|
||||
raw = '[{"type": "knowledge", "content": " "}, {"type": "knowledge", "content": "real"}]'
|
||||
result = _parse_candidates(raw, _make_interaction())
|
||||
assert len(result) == 1
|
||||
assert result[0].content == "real"
|
||||
|
||||
|
||||
def test_parser_clamps_confidence_to_unit_interval():
|
||||
raw = '[{"type": "knowledge", "content": "c1", "confidence": 2.5}, {"type": "knowledge", "content": "c2", "confidence": -0.4}]'
|
||||
result = _parse_candidates(raw, _make_interaction())
|
||||
assert result[0].confidence == 1.0
|
||||
assert result[1].confidence == 0.0
|
||||
|
||||
|
||||
def test_parser_defaults_confidence_on_missing_field():
|
||||
raw = '[{"type": "knowledge", "content": "c1"}]'
|
||||
result = _parse_candidates(raw, _make_interaction())
|
||||
assert result[0].confidence == 0.5
|
||||
|
||||
|
||||
def test_parser_tags_version_and_rule():
|
||||
raw = '[{"type": "project", "content": "c1"}]'
|
||||
result = _parse_candidates(raw, _make_interaction())
|
||||
assert result[0].rule == "llm_extraction"
|
||||
assert result[0].extractor_version == LLM_EXTRACTOR_VERSION
|
||||
assert result[0].source_interaction_id == "test-id"
|
||||
|
||||
|
||||
def test_case_a_empty_model_scoped_interaction():
|
||||
"""Case A: model returns empty project, interaction is scoped.
|
||||
Interaction scope wins."""
|
||||
raw = '[{"type": "project", "content": "machine works offline"}]'
|
||||
interaction = _make_interaction()
|
||||
interaction.project = "p06-polisher"
|
||||
result = _parse_candidates(raw, interaction)
|
||||
assert result[0].project == "p06-polisher"
|
||||
|
||||
|
||||
def test_case_b_empty_model_unscoped_interaction():
|
||||
"""Case B: both empty. Project stays empty."""
|
||||
raw = '[{"type": "project", "content": "generic fact"}]'
|
||||
interaction = _make_interaction()
|
||||
interaction.project = ""
|
||||
result = _parse_candidates(raw, interaction)
|
||||
assert result[0].project == ""
|
||||
|
||||
|
||||
def test_case_c_unregistered_model_scoped_interaction(tmp_data_dir, project_registry):
|
||||
"""Case C: model returns unregistered project, interaction is scoped.
|
||||
Interaction scope wins."""
|
||||
from atocore.models.database import init_db
|
||||
init_db()
|
||||
project_registry(("p06-polisher", ["p06"]))
|
||||
raw = '[{"type": "project", "content": "x", "project": "fake-project-99"}]'
|
||||
interaction = _make_interaction()
|
||||
interaction.project = "p06-polisher"
|
||||
result = _parse_candidates(raw, interaction)
|
||||
assert result[0].project == "p06-polisher"
|
||||
|
||||
|
||||
def test_case_d_unregistered_model_unscoped_keeps_tag(tmp_data_dir, project_registry):
|
||||
"""Case D: model returns unregistered project, interaction is unscoped.
|
||||
Keeps the model's tag for auto-project-detection (new behavior)."""
|
||||
from atocore.models.database import init_db
|
||||
init_db()
|
||||
project_registry(("p06-polisher", ["p06"]))
|
||||
raw = '[{"type": "project", "content": "x", "project": "new-lead-project"}]'
|
||||
interaction = _make_interaction()
|
||||
interaction.project = ""
|
||||
result = _parse_candidates(raw, interaction)
|
||||
assert result[0].project == "new-lead-project"
|
||||
|
||||
|
||||
def test_case_e_matching_model_and_interaction(tmp_data_dir, project_registry):
|
||||
"""Case E: model returns same project as interaction. Works."""
|
||||
from atocore.models.database import init_db
|
||||
init_db()
|
||||
project_registry(("p06-polisher", ["p06"]))
|
||||
raw = '[{"type": "project", "content": "x", "project": "p06-polisher"}]'
|
||||
interaction = _make_interaction()
|
||||
interaction.project = "p06-polisher"
|
||||
result = _parse_candidates(raw, interaction)
|
||||
assert result[0].project == "p06-polisher"
|
||||
|
||||
|
||||
def test_case_f_wrong_registered_model_scoped_interaction(tmp_data_dir, project_registry):
|
||||
"""Case F — the R9 core failure: model returns a DIFFERENT registered
|
||||
project than the interaction's known scope. Interaction scope wins.
|
||||
This is the case that was broken before the R9 fix."""
|
||||
from atocore.models.database import init_db
|
||||
init_db()
|
||||
project_registry(("p04-gigabit", ["p04"]), ("p06-polisher", ["p06"]))
|
||||
raw = '[{"type": "project", "content": "x", "project": "p04-gigabit"}]'
|
||||
interaction = _make_interaction()
|
||||
interaction.project = "p06-polisher"
|
||||
result = _parse_candidates(raw, interaction)
|
||||
assert result[0].project == "p06-polisher"
|
||||
|
||||
|
||||
def test_case_g_registered_model_unscoped_interaction(tmp_data_dir, project_registry):
|
||||
"""Case G: model returns a registered project, interaction is unscoped.
|
||||
Model project accepted (only way to get a project for unscoped captures)."""
|
||||
from atocore.models.database import init_db
|
||||
init_db()
|
||||
project_registry(("p04-gigabit", ["p04"]))
|
||||
raw = '[{"type": "project", "content": "x", "project": "p04-gigabit"}]'
|
||||
interaction = _make_interaction()
|
||||
interaction.project = ""
|
||||
result = _parse_candidates(raw, interaction)
|
||||
assert result[0].project == "p04-gigabit"
|
||||
|
||||
|
||||
def test_missing_cli_returns_empty(monkeypatch):
|
||||
"""If ``claude`` is not on PATH the extractor returns empty, never raises."""
|
||||
monkeypatch.setattr(extractor_llm, "_cli_available", lambda: False)
|
||||
result = extract_candidates_llm_verbose(_make_interaction("p", "some real response"))
|
||||
assert result.candidates == []
|
||||
assert result.error == "claude_cli_missing"
|
||||
|
||||
|
||||
def test_empty_response_returns_empty(monkeypatch):
|
||||
monkeypatch.setattr(extractor_llm, "_cli_available", lambda: True)
|
||||
result = extract_candidates_llm_verbose(_make_interaction("p", ""))
|
||||
assert result.candidates == []
|
||||
assert result.error == "empty_response"
|
||||
|
||||
|
||||
def test_subprocess_timeout_returns_empty(monkeypatch):
|
||||
"""A subprocess timeout must not raise into the caller."""
|
||||
monkeypatch.setattr(extractor_llm, "_cli_available", lambda: True)
|
||||
|
||||
import subprocess as _sp
|
||||
|
||||
def _boom(*a, **kw):
|
||||
raise _sp.TimeoutExpired(cmd=a[0] if a else "claude", timeout=1)
|
||||
|
||||
monkeypatch.setattr(extractor_llm.subprocess, "run", _boom)
|
||||
result = extract_candidates_llm_verbose(_make_interaction("p", "real response"))
|
||||
assert result.candidates == []
|
||||
assert result.error == "timeout"
|
||||
|
||||
|
||||
def test_subprocess_nonzero_exit_returns_empty(monkeypatch):
|
||||
"""A non-zero CLI exit (auth failure, etc.) must not raise."""
|
||||
monkeypatch.setattr(extractor_llm, "_cli_available", lambda: True)
|
||||
|
||||
class _Completed:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "auth failed"
|
||||
|
||||
monkeypatch.setattr(extractor_llm.subprocess, "run", lambda *a, **kw: _Completed())
|
||||
result = extract_candidates_llm_verbose(_make_interaction("p", "real response"))
|
||||
assert result.candidates == []
|
||||
assert result.error == "exit_1"
|
||||
|
||||
|
||||
def test_happy_path_parses_stdout(monkeypatch):
|
||||
monkeypatch.setattr(extractor_llm, "_cli_available", lambda: True)
|
||||
|
||||
class _Completed:
|
||||
returncode = 0
|
||||
stdout = '[{"type": "project", "content": "p04 selected Option B", "project": "p04-gigabit", "confidence": 0.6}]'
|
||||
stderr = ""
|
||||
|
||||
monkeypatch.setattr(extractor_llm.subprocess, "run", lambda *a, **kw: _Completed())
|
||||
result = extract_candidates_llm_verbose(_make_interaction("p", "r"))
|
||||
assert len(result.candidates) == 1
|
||||
assert result.candidates[0].memory_type == "project"
|
||||
assert result.candidates[0].project == "p04-gigabit"
|
||||
assert abs(result.candidates[0].confidence - 0.6) < 1e-9
|
||||
@@ -476,6 +476,60 @@ def test_reinforce_matches_at_70_percent_threshold(tmp_data_dir):
|
||||
assert any(r.memory_id == mem.id for r in results)
|
||||
|
||||
|
||||
def test_reinforce_long_memory_matches_on_absolute_overlap(tmp_data_dir):
|
||||
"""A paragraph-length memory should reinforce when the response
|
||||
echoes a substantive subset of its distinctive tokens, even though
|
||||
the overlap fraction stays well under 70%."""
|
||||
init_db()
|
||||
mem = create_memory(
|
||||
memory_type="project",
|
||||
content=(
|
||||
"Interferometer architecture: a folded-beam configuration with a "
|
||||
"fixed horizontal interferometer, a forty-five degree fold mirror, "
|
||||
"a six-DOF CGH stage, and the mirror on its own tilting platform. "
|
||||
"The fold mirror redirects the beam while the CGH shapes the wavefront."
|
||||
),
|
||||
project="p05-interferometer",
|
||||
confidence=0.5,
|
||||
)
|
||||
interaction = _make_interaction(
|
||||
project="p05-interferometer",
|
||||
response=(
|
||||
"For the interferometer we keep the folded-beam layout: horizontal "
|
||||
"interferometer, fold mirror at forty-five degrees, CGH stage with "
|
||||
"six DOF, and the mirror sitting on its tilting platform. The fold "
|
||||
"mirror redirects the beam and the CGH shapes the wavefront."
|
||||
),
|
||||
)
|
||||
results = reinforce_from_interaction(interaction)
|
||||
assert any(r.memory_id == mem.id for r in results)
|
||||
|
||||
|
||||
def test_reinforce_long_memory_rejects_thin_overlap(tmp_data_dir):
|
||||
"""Long memory + a response that only brushes a few generic terms
|
||||
must NOT reinforce — otherwise the reflection loop rots."""
|
||||
init_db()
|
||||
mem = create_memory(
|
||||
memory_type="project",
|
||||
content=(
|
||||
"Polisher control system executes approved controller jobs, "
|
||||
"enforces state transitions and interlocks, supports pause "
|
||||
"resume and abort, and records auditable run logs while "
|
||||
"never reinterpreting metrology or inventing new strategies."
|
||||
),
|
||||
project="p06-polisher",
|
||||
confidence=0.5,
|
||||
)
|
||||
interaction = _make_interaction(
|
||||
project="p06-polisher",
|
||||
response=(
|
||||
"I updated the polisher docs and fixed a typo in the run logs section."
|
||||
),
|
||||
)
|
||||
results = reinforce_from_interaction(interaction)
|
||||
assert all(r.memory_id != mem.id for r in results)
|
||||
|
||||
|
||||
def test_reinforce_rejects_below_70_percent(tmp_data_dir):
|
||||
"""Only 6 of 10 content tokens present (60%) → should NOT match."""
|
||||
init_db()
|
||||
|
||||
Reference in New Issue
Block a user