Compare commits
28 Commits
claude/ext
...
codex/audi
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 330ecfb6a6 |
@@ -6,11 +6,14 @@
|
||||
|
||||
## Orientation
|
||||
|
||||
- **live_sha** (Dalidou `/health` build_sha): `38f6e52`
|
||||
- **last_updated**: 2026-04-11 by Codex (review protocol formalized)
|
||||
- **main_tip**: `81307ce`
|
||||
- **test_count**: 264 passing
|
||||
- **harness**: `6/6 PASS` (`python scripts/retrieval_eval.py` against live Dalidou)
|
||||
- **live_sha** (Dalidou `/health` build_sha): `8951c62`
|
||||
- **last_updated**: 2026-04-12 by Codex (audit branch `codex/audit-batch2`)
|
||||
- **main_tip**: `69c9717`
|
||||
- **test_count**: `286 claimed`, but not reproducibly verified in this audit (`pytest` missing on Dalidou and in the clean audit worktree)
|
||||
- **harness**: `17/18 PASS` (only p06-tailscale still failing)
|
||||
- **active_memories**: 41
|
||||
- **candidate_memories**: 0
|
||||
- **project_state_entries**: p04=5, p05=6, p06=6 (Wave 2 entries still present on live Dalidou; 17 total visible)
|
||||
- **off_host_backup**: `papa@192.168.86.39:/home/papa/atocore-backups/` via cron env `ATOCORE_BACKUP_RSYNC`, verified
|
||||
|
||||
## Active Plan
|
||||
@@ -119,13 +122,26 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
|
||||
|
||||
| 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 | acknowledged | Claude | 2026-04-11 | |
|
||||
| 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. | open | Claude | 2026-04-12 | |
|
||||
| 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.
|
||||
@@ -136,6 +152,15 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
|
||||
|
||||
## Session Log
|
||||
|
||||
- **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.
|
||||
|
||||
54
deploy/dalidou/batch-extract.sh
Normal file
54
deploy/dalidou/batch-extract.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/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)"
|
||||
}
|
||||
|
||||
log "=== AtoCore batch extraction + triage complete ==="
|
||||
@@ -82,4 +82,22 @@ else
|
||||
log "Step 3: ATOCORE_BACKUP_RSYNC not set, skipping off-host copy"
|
||||
fi
|
||||
|
||||
# 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 ==="
|
||||
|
||||
@@ -27,7 +27,18 @@ read-only additive mode.
|
||||
### Partial
|
||||
|
||||
- Phase 4 - Identity / Preferences
|
||||
- Phase 8 - OpenClaw Integration
|
||||
|
||||
### Baseline Complete
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -226,14 +226,53 @@ candidate was a synthetic test capture from earlier in the session
|
||||
- Capture → reinforce is working correctly on live data (length-aware
|
||||
matcher verified on live paraphrase of a p04 memory).
|
||||
|
||||
Follow-up candidates (not yet scheduled):
|
||||
Follow-up candidates:
|
||||
|
||||
1. Extractor rule expansion — add conversational-form rules so real
|
||||
session text has a chance of surfacing candidates.
|
||||
2. LLM-assisted extractor as a separate rule family, guarded by
|
||||
confidence and always landing in `status=candidate` (never active).
|
||||
3. Retrieval eval harness — diffable scorecard of
|
||||
`formatted_context` across a fixed question set per active project.
|
||||
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
|
||||
|
||||
|
||||
247
scripts/auto_triage.py
Normal file
247
scripts/auto_triage.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""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", "confidence": 0.0-1.0, "reason": "one sentence"}
|
||||
|
||||
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. 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).
|
||||
|
||||
4. 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"}:
|
||||
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]
|
||||
return {"verdict": verdict, "confidence": confidence, "reason": reason}
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
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
|
||||
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()
|
||||
300
scripts/batch_llm_extract_live.py
Normal file
300
scripts/batch_llm_extract_live.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""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 durable memory candidates from LLM conversation turns for a personal context engine called AtoCore.
|
||||
|
||||
Your job is to read one user prompt plus the assistant's response and decide which durable facts, decisions, preferences, architectural rules, or project invariants should be remembered across future sessions.
|
||||
|
||||
Rules:
|
||||
|
||||
1. Only surface durable claims. Skip transient status ("deploy is still running"), instructional guidance ("here is how to run the command"), troubleshooting tactics, ephemeral recommendations ("merge this PR now"), and session recaps.
|
||||
2. A candidate is durable when a reader coming back in two weeks would still need to know it. Architectural choices, named rules, ratified decisions, invariants, procurement commitments, and project-level constraints qualify. Conversational fillers and step-by-step instructions do not.
|
||||
3. Each candidate must stand alone. Rewrite the claim in one sentence under 200 characters with enough context that a reader without the conversation understands it.
|
||||
4. Each candidate must have a type from this closed set: project, knowledge, preference, adaptation.
|
||||
5. If the conversation is clearly scoped to a project (p04-gigabit, p05-interferometer, p06-polisher, atocore), set ``project`` to that id. Otherwise leave ``project`` empty.
|
||||
6. If the response makes no durable claim, return an empty list. It is correct and expected to return [] on most conversational turns.
|
||||
7. Confidence should be 0.5 by default so human review workload is honest. Raise to 0.6 only when the response states the claim in an unambiguous, committed form (e.g. "the decision is X", "the selected approach is Y", "X is non-negotiable").
|
||||
8. Output must be a raw JSON array and nothing else. No prose before or after. No markdown fences. No explanations.
|
||||
|
||||
Each array element has exactly this shape:
|
||||
|
||||
{"type": "project|knowledge|preference|adaptation", "content": "...", "project": "...", "confidence": 0.5}
|
||||
|
||||
Return [] when there is nothing to extract."""
|
||||
|
||||
_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()
|
||||
project = str(item.get("project") or "").strip()
|
||||
if not project and interaction_project:
|
||||
project = interaction_project
|
||||
elif project and interaction_project and project != interaction_project:
|
||||
# R9: model hallucinated an unrecognized project — fall back.
|
||||
# The host-side script can't import the registry, so we
|
||||
# check against a known set fetched from the API.
|
||||
if project not in _known_projects:
|
||||
project = interaction_project
|
||||
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()
|
||||
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."}
|
||||
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"]}
|
||||
@@ -13,7 +13,7 @@
|
||||
"p06-polisher",
|
||||
"folded-beam"
|
||||
],
|
||||
"notes": "Canonical p04 decision — should surface both Trusted Project State (selected_mirror_architecture) and the project-memory band with the Option B memory"
|
||||
"notes": "Canonical p04 decision — should surface both Trusted Project State and the project-memory band"
|
||||
},
|
||||
{
|
||||
"name": "p04-constraints",
|
||||
@@ -27,7 +27,17 @@
|
||||
"expect_absent": [
|
||||
"polisher suite"
|
||||
],
|
||||
"notes": "Key constraints are in Trusted Project State (key_constraints) and in the mission-framing memory"
|
||||
"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",
|
||||
@@ -42,7 +52,7 @@
|
||||
"conical back",
|
||||
"polisher suite"
|
||||
],
|
||||
"notes": "P05 architecture memory covers folded-beam + CGH. GigaBIT M1 is the mirror under test and legitimately appears in p05 source docs (the interferometer measures it), so we only flag genuinely p04-only decisions like the mirror architecture choice."
|
||||
"notes": "P05 architecture memory covers folded-beam + CGH. GigaBIT M1 legitimately appears in p05 source docs."
|
||||
},
|
||||
{
|
||||
"name": "p05-vendor-signal",
|
||||
@@ -57,6 +67,19 @@
|
||||
],
|
||||
"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",
|
||||
@@ -69,7 +92,7 @@
|
||||
"expect_absent": [
|
||||
"GigaBIT"
|
||||
],
|
||||
"notes": "The three-layer split is in multiple p06 memories; check all three names surface together"
|
||||
"notes": "The three-layer split is in multiple p06 memories"
|
||||
},
|
||||
{
|
||||
"name": "p06-control-rule",
|
||||
@@ -82,5 +105,121 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -35,6 +35,10 @@ from atocore.memory.extractor import (
|
||||
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,
|
||||
@@ -580,6 +584,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")
|
||||
@@ -601,7 +606,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:
|
||||
@@ -755,6 +763,109 @@ def api_cleanup_backups(req: BackupCleanupRequest | None = None) -> dict:
|
||||
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/backup/{stamp}/validate")
|
||||
def api_validate_backup(stamp: str) -> dict:
|
||||
"""Validate that a previously created backup is structurally usable."""
|
||||
|
||||
@@ -27,9 +27,9 @@ Configuration:
|
||||
|
||||
- Requires the ``claude`` CLI on PATH (``claude --version`` should work).
|
||||
- ``ATOCORE_LLM_EXTRACTOR_MODEL`` overrides the model alias (default
|
||||
``haiku``).
|
||||
``sonnet``).
|
||||
- ``ATOCORE_LLM_EXTRACTOR_TIMEOUT_S`` overrides the per-call timeout
|
||||
(default 45 seconds — first invocation is slow because Node.js
|
||||
(default 90 seconds — first invocation is slow because Node.js
|
||||
startup plus OAuth check is non-trivial).
|
||||
|
||||
Implementation notes:
|
||||
@@ -65,7 +65,7 @@ from atocore.observability.logger import get_logger
|
||||
log = get_logger("extractor_llm")
|
||||
|
||||
LLM_EXTRACTOR_VERSION = "llm-0.2.0"
|
||||
DEFAULT_MODEL = os.environ.get("ATOCORE_LLM_EXTRACTOR_MODEL", "haiku")
|
||||
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
|
||||
@@ -168,7 +168,6 @@ def extract_candidates_llm_verbose(
|
||||
model or DEFAULT_MODEL,
|
||||
"--append-system-prompt",
|
||||
_SYSTEM_PROMPT,
|
||||
"--no-session-persistence",
|
||||
"--disable-slash-commands",
|
||||
user_message,
|
||||
]
|
||||
@@ -256,6 +255,29 @@ def _parse_candidates(raw_output: str, interaction: Interaction) -> list[MemoryC
|
||||
mem_type = str(item.get("type") or "").strip().lower()
|
||||
content = str(item.get("content") or "").strip()
|
||||
project = str(item.get("project") or "").strip()
|
||||
if not project and interaction.project:
|
||||
project = interaction.project
|
||||
elif project and interaction.project and project != interaction.project:
|
||||
# R9: model returned a different project than the interaction's
|
||||
# known scope. Trust the model's project only if it resolves
|
||||
# to a known registered project (the registry normalizes
|
||||
# aliases and returns the canonical id). If the model
|
||||
# hallucinated an unregistered project name, fall back to
|
||||
# the interaction's known 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(project)
|
||||
if resolved not in registered_ids:
|
||||
project = interaction.project
|
||||
else:
|
||||
project = resolved
|
||||
except Exception:
|
||||
project = interaction.project
|
||||
confidence_raw = item.get("confidence", 0.5)
|
||||
if mem_type not in MEMORY_TYPES:
|
||||
continue
|
||||
|
||||
@@ -413,8 +413,17 @@ def get_memories_for_context(
|
||||
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:
|
||||
entry = f"[{mem.memory_type}] {mem.content}"
|
||||
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
|
||||
@@ -437,20 +446,27 @@ def _rank_memories_for_query(
|
||||
) -> list["Memory"]:
|
||||
"""Rerank a memory list by lexical overlap with a pre-tokenized query.
|
||||
|
||||
Ordering key: (overlap_count DESC, confidence DESC). When a query
|
||||
shares no tokens with a memory, overlap is zero and confidence
|
||||
acts as the sole tiebreaker — which matches the pre-query
|
||||
behaviour and keeps no-query calls stable.
|
||||
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[int, float, Memory]] = []
|
||||
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
|
||||
scored.append((overlap, mem.confidence, mem))
|
||||
scored.sort(key=lambda t: (t[0], t[1]), reverse=True)
|
||||
return [mem for _, _, mem in scored]
|
||||
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:
|
||||
|
||||
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
|
||||
@@ -97,6 +97,41 @@ def test_parser_tags_version_and_rule():
|
||||
assert result[0].source_interaction_id == "test-id"
|
||||
|
||||
|
||||
def test_parser_falls_back_to_interaction_project():
|
||||
"""R6: when the model returns empty project but the interaction
|
||||
has one, the candidate should inherit the interaction's project."""
|
||||
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_parser_keeps_registered_model_project(tmp_data_dir, project_registry):
|
||||
"""R9: model-supplied project is kept when it's a registered project."""
|
||||
from atocore.models.database import init_db
|
||||
init_db()
|
||||
project_registry(("p04-gigabit", ["p04", "gigabit"]), ("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 == "p04-gigabit"
|
||||
|
||||
|
||||
def test_parser_rejects_hallucinated_project(tmp_data_dir, project_registry):
|
||||
"""R9: model-supplied project that is NOT registered falls back
|
||||
to the interaction's known project."""
|
||||
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_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)
|
||||
|
||||
Reference in New Issue
Block a user