From 53147d326c9d9c3fdf7417446194fd3cb36545d2 Mon Sep 17 00:00:00 2001 From: Anto01 Date: Mon, 6 Apr 2026 21:24:17 -0400 Subject: [PATCH] feat(phase9-C): rule-based candidate extractor and review queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 Commit C. Closes the capture loop: Commit A records what AtoCore fed the LLM and what came back, Commit B bumps confidence on active memories the response actually references, and this commit turns structured cues in the response into candidate memories for a human review queue. Nothing extracted here is ever automatically promoted into trusted state. Every candidate sits at status="candidate" until a human (or later, a confident automatic policy) calls /memory/{id}/promote or /memory/{id}/reject. This keeps the "bad memory is worse than no memory" invariant from the operating model intact. New module: src/atocore/memory/extractor.py - MemoryCandidate dataclass (type, content, rule, source_span, project, confidence, source_interaction_id) - extract_candidates_from_interaction(interaction): runs a fixed set of regex rules over the response + response_summary and returns a list of candidates V0 rule set (deliberately narrow to keep false positives low): - decision_heading ## Decision: / ## Decision - / ## Decision — -> adaptation candidate - constraint_heading ## Constraint: ... -> project candidate - requirement_heading ## Requirement: ... -> project candidate - fact_heading ## Fact: ... -> knowledge candidate - preference_sentence "I prefer X" / "the user prefers X" -> preference candidate - decided_to_sentence "decided to X" -> adaptation candidate - requirement_sentence "the requirement is X" -> project candidate Extractor post-processing: - clean_value: collapse whitespace, strip trailing punctuation - min content length 8 chars, max 280 (keeps candidates reviewable) - dedupe by (memory_type, normalized value, rule) - drop candidates whose content already matches an active memory of the same type+project so the queue doesn't ask humans to re-curate things they already promoted Memory service (extends Commit B candidate-status foundation): - promote_memory(id): candidate -> active (404 if not a candidate) - reject_candidate_memory(id): candidate -> invalid - both are no-ops if the target isn't currently a candidate so the API can surface 404 without the caller needing to pre-check API endpoints (new): - POST /interactions/{id}/extract run extractor, preview-only body: {"persist": false} (default) returns candidates {"persist": true} creates candidate memories - POST /memory/{id}/promote candidate -> active - POST /memory/{id}/reject candidate -> invalid - GET /memory?status=candidate list review queue explicitly (existing endpoint now accepts status= override) - GET /memory now also returns reference_count and last_referenced_at per memory so the Commit B reinforcement signal is visible to clients Trust model unchanged: - candidates NEVER appear in context packs (get_memories_for_context still filters to active via the active_only default) - candidates NEVER get reinforced by the Commit B loop (reinforcement refuses non-active memories) - trusted project state is untouched end-to-end Tests (25 new, all green): - heading pattern: decision, constraint, requirement, fact - separator variants :, -, em-dash - sentence patterns: preference, decided_to, requirement - rejects too-short matches - dedupes identical matches - strips trailing punctuation - carries project and source_interaction_id onto candidates - drops candidates that duplicate an existing active memory - returns empty for prose without structural cues - candidate and active coexist in the memory table - promote_memory moves candidate -> active - promote on non-candidate returns False - reject_candidate_memory moves candidate -> invalid - reject on non-candidate returns False - get_memories(status="candidate") returns just the queue - POST /interactions/{id}/extract preview-only path - POST /interactions/{id}/extract persist=true path - POST /interactions/{id}/extract 404 for missing interaction - POST /memory/{id}/promote success + 404 on non-candidate - POST /memory/{id}/reject 404 on missing - GET /memory?status=candidate surfaces the queue - GET /memory?status= returns 400 Full suite: 160 passing (was 135). What Phase 9 looks like end to end after this commit ---------------------------------------------------- prompt -> context pack assembled -> LLM response -> POST /interactions (capture) -> automatic Commit B reinforcement (active memories only) -> [optional] POST /interactions/{id}/extract -> Commit C extractor proposes candidates -> human reviews via GET /memory?status=candidate -> POST /memory/{id}/promote (candidate -> active) OR POST /memory/{id}/reject (candidate -> invalid) Not in this commit (deferred on purpose): - Decay of unused memories (we keep reference_count and last_referenced_at so a later decay job has the signal it needs) - LLM-based extractor as an alternative to the regex rules - Automatic promotion of high-confidence candidates - Candidate-to-entity upgrade path (needs the engineering layer memory-vs-entities decision, planned in a coming architecture doc) --- src/atocore/api/routes.py | 124 ++++++++++- src/atocore/memory/extractor.py | 229 +++++++++++++++++++ tests/test_extractor.py | 374 ++++++++++++++++++++++++++++++++ 3 files changed, 719 insertions(+), 8 deletions(-) create mode 100644 src/atocore/memory/extractor.py create mode 100644 tests/test_extractor.py diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index eaf2b14..1acb1fa 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -30,6 +30,10 @@ from atocore.interactions.service import ( list_interactions, record_interaction, ) +from atocore.memory.extractor import ( + MemoryCandidate, + extract_candidates_from_interaction, +) from atocore.memory.reinforcement import reinforce_from_interaction from atocore.memory.service import ( MEMORY_STATUSES, @@ -351,15 +355,25 @@ def api_get_memories( active_only: bool = True, min_confidence: float = 0.0, limit: int = 50, + status: str | None = None, ) -> dict: - """List memories, optionally filtered.""" - memories = get_memories( - memory_type=memory_type, - project=project, - active_only=active_only, - min_confidence=min_confidence, - limit=limit, - ) + """List memories, optionally filtered. + + When ``status`` is given explicitly it overrides ``active_only`` so + the Phase 9 Commit C review queue can be listed via + ``GET /memory?status=candidate``. + """ + try: + memories = get_memories( + memory_type=memory_type, + project=project, + active_only=active_only, + min_confidence=min_confidence, + limit=limit, + status=status, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) return { "memories": [ { @@ -369,11 +383,14 @@ def api_get_memories( "project": m.project, "confidence": m.confidence, "status": m.status, + "reference_count": m.reference_count, + "last_referenced_at": m.last_referenced_at, "updated_at": m.updated_at, } for m in memories ], "types": MEMORY_TYPES, + "statuses": MEMORY_STATUSES, } @@ -403,6 +420,33 @@ def api_invalidate_memory(memory_id: str) -> dict: return {"status": "invalidated", "id": memory_id} +@router.post("/memory/{memory_id}/promote") +def api_promote_memory(memory_id: str) -> dict: + """Promote a candidate memory to active (Phase 9 Commit C).""" + try: + success = promote_memory(memory_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + if not success: + raise HTTPException( + status_code=404, + detail=f"Memory not found or not a candidate: {memory_id}", + ) + return {"status": "promoted", "id": memory_id} + + +@router.post("/memory/{memory_id}/reject") +def api_reject_candidate_memory(memory_id: str) -> dict: + """Reject a candidate memory (Phase 9 Commit C review queue).""" + success = reject_candidate_memory(memory_id) + if not success: + raise HTTPException( + status_code=404, + detail=f"Memory not found or not a candidate: {memory_id}", + ) + return {"status": "rejected", "id": memory_id} + + @router.post("/project/state") def api_set_project_state(req: ProjectStateSetRequest) -> dict: """Set or update a trusted project state entry.""" @@ -528,6 +572,70 @@ def api_reinforce_interaction(interaction_id: str) -> dict: } +class InteractionExtractRequest(BaseModel): + persist: bool = False + + +@router.post("/interactions/{interaction_id}/extract") +def api_extract_from_interaction( + interaction_id: str, + req: InteractionExtractRequest | None = None, +) -> dict: + """Extract candidate memories from a captured interaction. + + Phase 9 Commit C. The extractor is rule-based and deliberately + conservative — it only surfaces candidates that matched an explicit + structural cue (decision heading, preference sentence, etc.). By + default the candidates are returned *without* being persisted so a + caller can preview them before committing to a review queue. Pass + ``persist: true`` to immediately create candidate memories for + each extraction result. + """ + interaction = get_interaction(interaction_id) + 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) + + persisted_ids: list[str] = [] + if payload.persist: + for candidate in candidates: + try: + mem = create_memory( + memory_type=candidate.memory_type, + content=candidate.content, + project=candidate.project, + confidence=candidate.confidence, + status="candidate", + ) + persisted_ids.append(mem.id) + except ValueError as e: + log.error( + "extract_persist_failed", + interaction_id=interaction_id, + rule=candidate.rule, + error=str(e), + ) + + return { + "interaction_id": interaction_id, + "candidate_count": len(candidates), + "persisted": payload.persist, + "persisted_ids": persisted_ids, + "candidates": [ + { + "memory_type": c.memory_type, + "content": c.content, + "project": c.project, + "confidence": c.confidence, + "rule": c.rule, + "source_span": c.source_span, + } + for c in candidates + ], + } + + @router.get("/interactions") def api_list_interactions( project: str | None = None, diff --git a/src/atocore/memory/extractor.py b/src/atocore/memory/extractor.py new file mode 100644 index 0000000..f7513b7 --- /dev/null +++ b/src/atocore/memory/extractor.py @@ -0,0 +1,229 @@ +"""Rule-based candidate-memory extraction from captured interactions. + +Phase 9 Commit C. This module reads an interaction's response text and +produces a list of *candidate* memories that a human can later review +and either promote to active or reject. Nothing extracted here is ever +automatically promoted into trusted state — the AtoCore trust rule is +that bad memory is worse than no memory, so the extractor is +conservative on purpose. + +Design rules for V0 +------------------- +1. Rule-based only. No LLM calls. The extractor should be fast, cheap, + fully explainable, and produce the same output for the same input + across runs. +2. Patterns match obvious, high-signal structures and are intentionally + narrow. False positives are more harmful than false negatives because + every candidate means review work for a human. +3. Every extracted candidate records which pattern fired and which text + span it came from, so a reviewer can audit the extractor's reasoning. +4. Patterns should feel like idioms the user already writes in their + PKM and interaction notes: + * ``## Decision: ...`` and variants + * ``## Constraint: ...`` and variants + * ``I prefer `` / ``the user prefers `` + * ``decided to `` + * `` is a requirement`` / ``requirement: `` +5. Candidates are de-duplicated against already-active memories of the + same type+project so review queues don't fill up with things the + user has already curated. + +The extractor produces ``MemoryCandidate`` objects. The caller decides +whether to persist them via ``create_memory(..., status="candidate")``. +Persistence is kept out of the extractor itself so it can be tested +without touching the database and so future extractors (LLM-based, +structural, ontology-driven) can be swapped in cleanly. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from atocore.interactions.service import Interaction +from atocore.memory.service import MEMORY_TYPES, get_memories +from atocore.observability.logger import get_logger + +log = get_logger("extractor") + +# Every candidate is attributed to the rule that fired so reviewers can +# audit why it was proposed. +@dataclass +class MemoryCandidate: + memory_type: str + content: str + rule: str + source_span: str + project: str = "" + confidence: float = 0.5 # default review-queue confidence + source_interaction_id: str = "" + + +# --------------------------------------------------------------------------- +# Pattern definitions +# --------------------------------------------------------------------------- +# +# Each pattern maps to: +# - the memory type the candidate should land in +# - a compiled regex over the response text +# - a short human-readable rule id +# +# Regexes are intentionally anchored to obvious structural cues so random +# prose doesn't light them up. All are case-insensitive and DOTALL so +# they can span a line break inside a single logical phrase. + +_RULES: list[tuple[str, str, re.Pattern]] = [ + ( + "decision_heading", + "adaptation", + re.compile( + r"^[ \t]*#{1,6}[ \t]*decision[ \t]*[:\-\u2014][ \t]*(?P.+?)$", + re.IGNORECASE | re.MULTILINE, + ), + ), + ( + "constraint_heading", + "project", + re.compile( + r"^[ \t]*#{1,6}[ \t]*constraint[ \t]*[:\-\u2014][ \t]*(?P.+?)$", + re.IGNORECASE | re.MULTILINE, + ), + ), + ( + "requirement_heading", + "project", + re.compile( + r"^[ \t]*#{1,6}[ \t]*requirement[ \t]*[:\-\u2014][ \t]*(?P.+?)$", + re.IGNORECASE | re.MULTILINE, + ), + ), + ( + "fact_heading", + "knowledge", + re.compile( + r"^[ \t]*#{1,6}[ \t]*fact[ \t]*[:\-\u2014][ \t]*(?P.+?)$", + re.IGNORECASE | re.MULTILINE, + ), + ), + ( + "preference_sentence", + "preference", + re.compile( + r"(?:^|[\s\.])(?:I|the user)\s+prefer(?:s)?\s+(?P[^\n\.\!]{6,200})", + re.IGNORECASE, + ), + ), + ( + "decided_to_sentence", + "adaptation", + re.compile( + r"(?:^|[\s\.])(?:I|we|the user)\s+decided\s+to\s+(?P[^\n\.\!]{6,200})", + re.IGNORECASE, + ), + ), + ( + "requirement_sentence", + "project", + re.compile( + r"(?:^|[\s\.])(?:the[ \t]+)?requirement\s+(?:is|was)\s+(?P[^\n\.\!]{6,200})", + re.IGNORECASE, + ), + ), +] + +# A minimum content length after trimming stops silly one-word candidates. +_MIN_CANDIDATE_LENGTH = 8 +# A maximum content length keeps candidates reviewable at a glance. +_MAX_CANDIDATE_LENGTH = 280 + + +def extract_candidates_from_interaction( + interaction: Interaction, +) -> list[MemoryCandidate]: + """Return a list of candidate memories for human review. + + The returned candidates are not persisted. The caller can iterate + over the result and call ``create_memory(..., status="candidate")`` + for each one it wants to land. + """ + text = _combined_response_text(interaction) + if not text: + return [] + + raw_candidates: list[MemoryCandidate] = [] + seen_spans: set[tuple[str, str, str]] = set() # (type, normalized_value, rule) + + for rule_id, memory_type, pattern in _RULES: + for match in pattern.finditer(text): + value = _clean_value(match.group("value")) + if len(value) < _MIN_CANDIDATE_LENGTH or len(value) > _MAX_CANDIDATE_LENGTH: + continue + normalized = value.lower() + dedup_key = (memory_type, normalized, rule_id) + if dedup_key in seen_spans: + continue + seen_spans.add(dedup_key) + raw_candidates.append( + MemoryCandidate( + memory_type=memory_type, + content=value, + rule=rule_id, + source_span=match.group(0).strip(), + project=interaction.project or "", + confidence=0.5, + source_interaction_id=interaction.id, + ) + ) + + # Drop anything that duplicates an already-active memory of the + # same type and project so reviewers aren't asked to re-curate + # things they already promoted. + filtered = [c for c in raw_candidates if not _matches_existing_active(c)] + + if filtered: + log.info( + "extraction_produced_candidates", + interaction_id=interaction.id, + candidate_count=len(filtered), + dropped_as_duplicate=len(raw_candidates) - len(filtered), + ) + return filtered + + +def _combined_response_text(interaction: Interaction) -> str: + parts: list[str] = [] + if interaction.response: + parts.append(interaction.response) + if interaction.response_summary: + parts.append(interaction.response_summary) + return "\n".join(parts).strip() + + +def _clean_value(raw: str) -> str: + """Trim whitespace, strip trailing punctuation, collapse inner spaces.""" + cleaned = re.sub(r"\s+", " ", raw).strip() + # Trim trailing punctuation that commonly trails sentences but is not + # part of the fact itself. + cleaned = cleaned.rstrip(".;,!?\u2014-") + return cleaned.strip() + + +def _matches_existing_active(candidate: MemoryCandidate) -> bool: + """Return True if an identical active memory already exists.""" + if candidate.memory_type not in MEMORY_TYPES: + return False + try: + existing = get_memories( + memory_type=candidate.memory_type, + project=candidate.project or None, + active_only=True, + limit=200, + ) + except Exception as exc: # pragma: no cover - defensive + log.error("extractor_existing_lookup_failed", error=str(exc)) + return False + needle = candidate.content.lower() + for mem in existing: + if mem.content.lower() == needle: + return True + return False diff --git a/tests/test_extractor.py b/tests/test_extractor.py new file mode 100644 index 0000000..d23c9bf --- /dev/null +++ b/tests/test_extractor.py @@ -0,0 +1,374 @@ +"""Tests for Phase 9 Commit C rule-based candidate extractor.""" + +from fastapi.testclient import TestClient + +from atocore.interactions.service import record_interaction +from atocore.main import app +from atocore.memory.extractor import ( + MemoryCandidate, + extract_candidates_from_interaction, +) +from atocore.memory.service import ( + create_memory, + get_memories, + promote_memory, + reject_candidate_memory, +) +from atocore.models.database import init_db + + +def _capture(**fields): + return record_interaction( + prompt=fields.get("prompt", "unused"), + response=fields.get("response", ""), + response_summary=fields.get("response_summary", ""), + project=fields.get("project", ""), + reinforce=False, + ) + + +# --- extractor: heading patterns ------------------------------------------ + + +def test_extractor_finds_decision_heading(tmp_data_dir): + init_db() + interaction = _capture( + response=( + "We talked about the frame.\n\n" + "## Decision: switch the lateral supports to GF-PTFE pads\n\n" + "Rationale: thermal stability." + ), + ) + results = extract_candidates_from_interaction(interaction) + assert len(results) == 1 + assert results[0].memory_type == "adaptation" + assert "GF-PTFE" in results[0].content + assert results[0].rule == "decision_heading" + + +def test_extractor_finds_constraint_and_requirement_headings(tmp_data_dir): + init_db() + interaction = _capture( + response=( + "### Constraint: total mass must stay under 4.8 kg\n" + "## Requirement: survives 12g shock in any axis\n" + ), + ) + results = extract_candidates_from_interaction(interaction) + rules = {r.rule for r in results} + assert "constraint_heading" in rules + assert "requirement_heading" in rules + constraint = next(r for r in results if r.rule == "constraint_heading") + requirement = next(r for r in results if r.rule == "requirement_heading") + assert constraint.memory_type == "project" + assert requirement.memory_type == "project" + assert "4.8 kg" in constraint.content + assert "12g" in requirement.content + + +def test_extractor_finds_fact_heading(tmp_data_dir): + init_db() + interaction = _capture( + response="## Fact: the polisher sim uses floating-point deltas in microns\n", + ) + results = extract_candidates_from_interaction(interaction) + assert len(results) == 1 + assert results[0].memory_type == "knowledge" + assert results[0].rule == "fact_heading" + + +def test_extractor_heading_separator_variants(tmp_data_dir): + """Decision headings should match with `:`, `-`, or em-dash.""" + init_db() + for sep in (":", "-", "\u2014"): + interaction = _capture( + response=f"## Decision {sep} adopt option B for the mount interface\n", + ) + results = extract_candidates_from_interaction(interaction) + assert len(results) == 1, f"sep={sep!r}" + assert "option B" in results[0].content + + +# --- extractor: sentence patterns ----------------------------------------- + + +def test_extractor_finds_preference_sentence(tmp_data_dir): + init_db() + interaction = _capture( + response=( + "I prefer rebase-based workflows because history stays linear " + "and reviewers have an easier time." + ), + ) + results = extract_candidates_from_interaction(interaction) + pref_matches = [r for r in results if r.rule == "preference_sentence"] + assert len(pref_matches) == 1 + assert pref_matches[0].memory_type == "preference" + assert "rebase" in pref_matches[0].content.lower() + + +def test_extractor_finds_decided_to_sentence(tmp_data_dir): + init_db() + interaction = _capture( + response=( + "After going through the options we decided to keep the legacy " + "calibration routine for the July milestone." + ), + ) + results = extract_candidates_from_interaction(interaction) + decision_matches = [r for r in results if r.rule == "decided_to_sentence"] + assert len(decision_matches) == 1 + assert decision_matches[0].memory_type == "adaptation" + assert "legacy calibration" in decision_matches[0].content.lower() + + +def test_extractor_finds_requirement_sentence(tmp_data_dir): + init_db() + interaction = _capture( + response=( + "One of the findings: the requirement is that the interferometer " + "must resolve 50 picometer displacements at 1 kHz bandwidth." + ), + ) + results = extract_candidates_from_interaction(interaction) + req_matches = [r for r in results if r.rule == "requirement_sentence"] + assert len(req_matches) == 1 + assert req_matches[0].memory_type == "project" + assert "picometer" in req_matches[0].content.lower() + + +# --- extractor: content rules --------------------------------------------- + + +def test_extractor_rejects_too_short_matches(tmp_data_dir): + init_db() + interaction = _capture(response="## Decision: yes\n") # too short after clean + results = extract_candidates_from_interaction(interaction) + assert results == [] + + +def test_extractor_deduplicates_identical_matches(tmp_data_dir): + init_db() + interaction = _capture( + response=( + "## Decision: use the modular frame variant for prototyping\n" + "## Decision: use the modular frame variant for prototyping\n" + ), + ) + results = extract_candidates_from_interaction(interaction) + assert len(results) == 1 + + +def test_extractor_strips_trailing_punctuation(tmp_data_dir): + init_db() + interaction = _capture( + response="## Decision: defer the laser redesign to Q3.\n", + ) + results = extract_candidates_from_interaction(interaction) + assert len(results) == 1 + assert results[0].content.endswith("Q3") + + +def test_extractor_includes_project_and_source_interaction_id(tmp_data_dir): + init_db() + interaction = _capture( + project="p05-interferometer", + response="## Decision: freeze the optical path for the prototype run\n", + ) + results = extract_candidates_from_interaction(interaction) + assert len(results) == 1 + assert results[0].project == "p05-interferometer" + assert results[0].source_interaction_id == interaction.id + + +def test_extractor_drops_candidates_matching_existing_active(tmp_data_dir): + init_db() + # Seed an active memory that the extractor would otherwise re-propose + create_memory( + memory_type="preference", + content="prefers small reviewable diffs", + ) + interaction = _capture( + response="Remember that I prefer small reviewable diffs because they merge faster.", + ) + results = extract_candidates_from_interaction(interaction) + # The only candidate would have been the preference, now dropped + assert not any(r.content.lower() == "small reviewable diffs" for r in results) + + +def test_extractor_returns_empty_for_no_patterns(tmp_data_dir): + init_db() + interaction = _capture(response="Nothing structural here, just prose.") + results = extract_candidates_from_interaction(interaction) + assert results == [] + + +# --- service: candidate lifecycle ----------------------------------------- + + +def test_candidate_and_active_can_coexist(tmp_data_dir): + init_db() + active = create_memory( + memory_type="preference", + content="logs every config change to the change log", + status="active", + ) + candidate = create_memory( + memory_type="preference", + content="logs every config change to the change log", + status="candidate", + ) + # The two are distinct rows because status is part of the dedup key + assert active.id != candidate.id + + +def test_promote_memory_moves_candidate_to_active(tmp_data_dir): + init_db() + candidate = create_memory( + memory_type="adaptation", + content="moved the staging scripts into deploy/staging", + status="candidate", + ) + ok = promote_memory(candidate.id) + assert ok is True + + active_list = get_memories(memory_type="adaptation", status="active") + assert any(m.id == candidate.id for m in active_list) + + +def test_promote_memory_on_non_candidate_returns_false(tmp_data_dir): + init_db() + active = create_memory( + memory_type="adaptation", + content="already active adaptation entry", + status="active", + ) + assert promote_memory(active.id) is False + + +def test_reject_candidate_moves_it_to_invalid(tmp_data_dir): + init_db() + candidate = create_memory( + memory_type="knowledge", + content="the calibration uses barometric pressure compensation", + status="candidate", + ) + ok = reject_candidate_memory(candidate.id) + assert ok is True + + invalid_list = get_memories(memory_type="knowledge", status="invalid") + assert any(m.id == candidate.id for m in invalid_list) + + +def test_reject_on_non_candidate_returns_false(tmp_data_dir): + init_db() + active = create_memory(memory_type="preference", content="always uses structured logging") + assert reject_candidate_memory(active.id) is False + + +def test_get_memories_filters_by_candidate_status(tmp_data_dir): + init_db() + create_memory(memory_type="preference", content="active one", status="active") + create_memory(memory_type="preference", content="candidate one", status="candidate") + create_memory(memory_type="preference", content="another candidate", status="candidate") + candidates = get_memories(status="candidate", memory_type="preference") + assert len(candidates) == 2 + assert all(c.status == "candidate" for c in candidates) + + +# --- API: extract / promote / reject / list ------------------------------- + + +def test_api_extract_interaction_without_persist(tmp_data_dir): + init_db() + interaction = record_interaction( + prompt="review", + response="## Decision: flip the default budget to 4000 for p05\n", + reinforce=False, + ) + client = TestClient(app) + response = client.post(f"/interactions/{interaction.id}/extract", json={}) + assert response.status_code == 200 + body = response.json() + assert body["candidate_count"] == 1 + assert body["persisted"] is False + assert body["persisted_ids"] == [] + # The candidate should NOT have been written to the memory table + queue = get_memories(status="candidate") + assert queue == [] + + +def test_api_extract_interaction_with_persist(tmp_data_dir): + init_db() + interaction = record_interaction( + prompt="review", + response=( + "## Decision: pin the embedding model to v2.3 for Wave 2\n" + "## Constraint: context budget must stay under 4000 chars\n" + ), + reinforce=False, + ) + client = TestClient(app) + response = client.post( + f"/interactions/{interaction.id}/extract", json={"persist": True} + ) + assert response.status_code == 200 + body = response.json() + assert body["candidate_count"] == 2 + assert body["persisted"] is True + assert len(body["persisted_ids"]) == 2 + + queue = get_memories(status="candidate", limit=50) + assert len(queue) == 2 + + +def test_api_extract_returns_404_for_missing_interaction(tmp_data_dir): + init_db() + client = TestClient(app) + response = client.post("/interactions/nope/extract", json={}) + assert response.status_code == 404 + + +def test_api_promote_and_reject_endpoints(tmp_data_dir): + init_db() + candidate = create_memory( + memory_type="adaptation", + content="restructured the ingestion pipeline into layered stages", + status="candidate", + ) + client = TestClient(app) + + promote_response = client.post(f"/memory/{candidate.id}/promote") + assert promote_response.status_code == 200 + assert promote_response.json()["status"] == "promoted" + + # Promoting it again should 404 because it's no longer a candidate + second_promote = client.post(f"/memory/{candidate.id}/promote") + assert second_promote.status_code == 404 + + reject_response = client.post("/memory/does-not-exist/reject") + assert reject_response.status_code == 404 + + +def test_api_get_memory_candidate_status_filter(tmp_data_dir): + init_db() + create_memory(memory_type="preference", content="prefers explicit types", status="active") + create_memory( + memory_type="preference", + content="prefers pull requests sized by diff lines not files", + status="candidate", + ) + client = TestClient(app) + response = client.get("/memory", params={"status": "candidate"}) + assert response.status_code == 200 + body = response.json() + assert "candidate" in body["statuses"] + assert len(body["memories"]) == 1 + assert body["memories"][0]["status"] == "candidate" + + +def test_api_get_memory_invalid_status_returns_400(tmp_data_dir): + init_db() + client = TestClient(app) + response = client.get("/memory", params={"status": "not-a-status"}) + assert response.status_code == 400