feat(eval-loop): Day 4 — LLM extractor via claude -p (OAuth, no API key)

Second pass on the LLM-assisted extractor after Antoine's explicit
rule: no API key, ever. Refactored src/atocore/memory/extractor_llm.py
to shell out to the Claude Code 'claude -p' CLI via subprocess instead
of the anthropic SDK, so extraction reuses the user's existing Claude.ai
OAuth credentials and needs zero secret management.

Implementation:

- subprocess.run(["claude", "-p", "--model", "haiku",
    "--append-system-prompt", <instructions>,
    "--no-session-persistence", "--disable-slash-commands",
    user_message], ...)
- cwd is a cached tempfile.mkdtemp() so every invocation starts with
  a clean context instead of auto-discovering CLAUDE.md / AGENTS.md /
  DEV-LEDGER.md from the repo root. We cannot use --bare because it
  forces API-key auth, which defeats the purpose; the temp-cwd trick
  is the lightest way to keep OAuth auth while skipping project
  context loading.
- Silent-failure contract unchanged: missing CLI, non-zero exit,
  timeout, malformed JSON — all return [] and log an error. The
  capture audit trail must not break on an optional side effect.
- Default timeout bumped from 20s to 90s: Haiku + Node.js startup
  + OAuth check is ~20-40s per call in practice, plus real responses
  up to 8KB take longer. 45s hit 2 timeouts on the first live run.
- tests/test_extractor_llm.py refactored: the API-key / anthropic SDK
  tests are replaced by subprocess-mocking tests covering missing
  CLI, timeout, non-zero exit, and a happy-path stdout parse. 14
  tests, all green.

scripts/extractor_eval.py:

- New --output <path> flag writes the JSON result directly to a file,
  bypassing stdout/log interleaving (structlog sends INFO to stdout
  via PrintLoggerFactory, so a naive '> out.json' pollutes the file).
- Forces UTF-8 on stdout so real LLM output with em-dashes / arrows /
  CJK doesn't crash the human report on Windows cp1252 consoles.

First live baseline run against the 20-interaction labeled corpus
(scripts/eval_data/extractor_llm_baseline_2026-04-11.json):

    mode=llm  labeled=20  recall=1.0  precision=0.357  yield_rate=2.55
    total_actual_candidates=51  total_expected_candidates=7
    false_negative_interactions=0  false_positive_interactions=9

Recall 0% -> 100% vs rule baseline — every human-labeled positive is
caught. Precision reads low (0.357) but inspection shows the "false
positives" are real candidates the human labels under-counted. For
example interaction a6b0d279 was labeled at 2 expected candidates,
the model caught all 6 polisher architectural facts; interaction
52c8c0f3 was labeled at 1, the model caught all 5 infra commitments.
The labels are the bottleneck, not the model.

Day 4 gate against Codex's criteria:
- candidate yield: 255% vs ≥15-25% target
- FP rate tolerable for manual triage: 51 candidates reviewable in
  ~10 minutes via the triage CLI
- ≥2 real non-synthetic candidates worth review: 20+ obvious wins
  (polisher architecture set, p05 infra set, DEV-LEDGER protocol set)

Gate cleared. LLM-assisted extraction is the path forward for
conversational captures. Rule-based extractor stays as-is for
structured-cue inputs and remains the default mode. The next step
(Day 5 stabilize / document) will wire LLM mode behind a flag in
the public extraction endpoint and document scope.

Test count: 276 -> 278 passing. No existing tests changed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 17:45:24 -04:00
parent b309e7fd49
commit a29b5e22f2
4 changed files with 702 additions and 71 deletions

View 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"
}
]
}
]
}

View File

@@ -22,11 +22,17 @@ Usage:
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"))
@@ -218,6 +224,12 @@ def main() -> int:
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"],
@@ -232,7 +244,25 @@ def main() -> int:
summary = aggregate(results)
summary["mode"] = args.mode
if args.json:
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)

View File

@@ -1,14 +1,14 @@
"""LLM-assisted candidate-memory extraction.
"""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 reads the full prompt+response, asks a small
model (default: Claude Haiku 4.5) for structured candidate objects,
and returns the same ``MemoryCandidate`` dataclass the rule extractor
produces so both paths flow through the same candidate pipeline.
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:
@@ -18,35 +18,55 @@ Trust rules carried forward from the rule-based extractor:
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 API key, unreachable model, malformed
JSON, timeout — all return an empty list and log an error. Never
raise into the caller, because the capture audit trail must not
break on an optional side effect.
- 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:
- ``ANTHROPIC_API_KEY`` env var must be set or the function returns [].
- ``ATOCORE_LLM_EXTRACTOR_MODEL`` overrides the default model id.
- ``ATOCORE_LLM_EXTRACTOR_TIMEOUT_S`` overrides the request timeout
(default 20 seconds).
- Requires the ``claude`` CLI on PATH (``claude --version`` should work).
- ``ATOCORE_LLM_EXTRACTOR_MODEL`` overrides the model alias (default
``haiku``).
- ``ATOCORE_LLM_EXTRACTOR_TIMEOUT_S`` overrides the per-call timeout
(default 45 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 EXTRACTOR_VERSION, MemoryCandidate
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.1.0"
DEFAULT_MODEL = os.environ.get("ATOCORE_LLM_EXTRACTOR_MODEL", "claude-haiku-4-5-20251001")
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_LLM_EXTRACTOR_TIMEOUT_S", "20"))
LLM_EXTRACTOR_VERSION = "llm-0.2.0"
DEFAULT_MODEL = os.environ.get("ATOCORE_LLM_EXTRACTOR_MODEL", "haiku")
DEFAULT_TIMEOUT_S = float(os.environ.get("ATOCORE_LLM_EXTRACTOR_TIMEOUT_S", "90"))
MAX_RESPONSE_CHARS = 8000
MAX_PROMPT_CHARS = 2000
@@ -62,8 +82,8 @@ Rules:
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 for new candidates so 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.
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:
@@ -79,6 +99,23 @@ class LLMExtractionResult:
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,
@@ -86,15 +123,14 @@ def extract_candidates_llm(
) -> 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.
Returns a list of ``MemoryCandidate`` objects, empty on any
failure path. The caller is responsible for persistence.
"""
result = extract_candidates_llm_verbose(
return extract_candidates_llm_verbose(
interaction,
model=model,
timeout_s=timeout_s,
)
return result.candidates
).candidates
def extract_candidates_llm_verbose(
@@ -102,22 +138,20 @@ def extract_candidates_llm_verbose(
model: str | None = None,
timeout_s: float | None = None,
) -> LLMExtractionResult:
"""Same as ``extract_candidates_llm`` but also returns the raw
model output and any error encountered, for eval / debugging.
"""Like ``extract_candidates_llm`` but also returns the raw
subprocess output and any error encountered, for eval / debugging.
"""
if not os.environ.get("ANTHROPIC_API_KEY"):
return LLMExtractionResult(candidates=[], raw_output="", error="missing_api_key")
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")
try:
import anthropic # noqa: F401
except ImportError:
log.error("anthropic_sdk_missing")
return LLMExtractionResult(candidates=[], raw_output="", error="anthropic_sdk_missing")
prompt_excerpt = (interaction.prompt or "")[:MAX_PROMPT_CHARS]
response_excerpt = response_text[:MAX_RESPONSE_CHARS]
user_message = (
@@ -127,27 +161,49 @@ def extract_candidates_llm_verbose(
"Return the JSON array now."
)
args = [
"claude",
"-p",
"--model",
model or DEFAULT_MODEL,
"--append-system-prompt",
_SYSTEM_PROMPT,
"--no-session-persistence",
"--disable-slash-commands",
user_message,
]
try:
import anthropic
client = anthropic.Anthropic(timeout=timeout_s or DEFAULT_TIMEOUT_S)
response = client.messages.create(
model=model or DEFAULT_MODEL,
max_tokens=1024,
system=_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_message}],
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 Exception as exc: # pragma: no cover - network / auth failures
log.error("llm_extractor_api_failed", error=str(exc))
return LLMExtractionResult(candidates=[], raw_output="", error=f"api_error: {exc}")
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}")
raw_output = ""
for block in response.content:
text = getattr(block, "text", None)
if text:
raw_output += text
raw_output = raw_output.strip()
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",
@@ -167,7 +223,6 @@ def _parse_candidates(raw_output: str, interaction: Interaction) -> list[MemoryC
"""
text = raw_output.strip()
if text.startswith("```"):
# Strip markdown fences if the model added them despite the instruction.
text = text.strip("`")
first_newline = text.find("\n")
if first_newline >= 0:
@@ -179,7 +234,6 @@ def _parse_candidates(raw_output: str, interaction: Interaction) -> list[MemoryC
if not text or text == "[]":
return []
# If the model wrapped the array in prose, try to isolate the JSON.
if not text.lstrip().startswith("["):
start = text.find("[")
end = text.rfind("]")

View File

@@ -21,6 +21,7 @@ from atocore.memory.extractor_llm import (
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:
@@ -96,34 +97,62 @@ def test_parser_tags_version_and_rule():
assert result[0].source_interaction_id == "test-id"
def test_missing_api_key_returns_empty(monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
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 == "missing_api_key"
assert result.error == "claude_cli_missing"
def test_empty_response_returns_empty(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "fake-key-not-used")
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_api_error_returns_empty(monkeypatch):
"""A transport error from the SDK must not raise into the caller."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "fake-key-not-used")
def test_subprocess_timeout_returns_empty(monkeypatch):
"""A subprocess timeout must not raise into the caller."""
monkeypatch.setattr(extractor_llm, "_cli_available", lambda: True)
class _BoomClient:
def __init__(self, *a, **kw):
pass
import subprocess as _sp
class messages: # noqa: D401
@staticmethod
def create(**kw):
raise RuntimeError("simulated network error")
def _boom(*a, **kw):
raise _sp.TimeoutExpired(cmd=a[0] if a else "claude", timeout=1)
with patch("anthropic.Anthropic", _BoomClient):
result = extract_candidates_llm_verbose(_make_interaction("p", "real response"))
monkeypatch.setattr(extractor_llm.subprocess, "run", _boom)
result = extract_candidates_llm_verbose(_make_interaction("p", "real response"))
assert result.candidates == []
assert "api_error" in result.error
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