feat(phase9-C): rule-based candidate extractor and review queue
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=<invalid> 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)
This commit is contained in:
@@ -30,6 +30,10 @@ from atocore.interactions.service import (
|
|||||||
list_interactions,
|
list_interactions,
|
||||||
record_interaction,
|
record_interaction,
|
||||||
)
|
)
|
||||||
|
from atocore.memory.extractor import (
|
||||||
|
MemoryCandidate,
|
||||||
|
extract_candidates_from_interaction,
|
||||||
|
)
|
||||||
from atocore.memory.reinforcement import reinforce_from_interaction
|
from atocore.memory.reinforcement import reinforce_from_interaction
|
||||||
from atocore.memory.service import (
|
from atocore.memory.service import (
|
||||||
MEMORY_STATUSES,
|
MEMORY_STATUSES,
|
||||||
@@ -351,15 +355,25 @@ def api_get_memories(
|
|||||||
active_only: bool = True,
|
active_only: bool = True,
|
||||||
min_confidence: float = 0.0,
|
min_confidence: float = 0.0,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
status: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List memories, optionally filtered."""
|
"""List memories, optionally filtered.
|
||||||
memories = get_memories(
|
|
||||||
memory_type=memory_type,
|
When ``status`` is given explicitly it overrides ``active_only`` so
|
||||||
project=project,
|
the Phase 9 Commit C review queue can be listed via
|
||||||
active_only=active_only,
|
``GET /memory?status=candidate``.
|
||||||
min_confidence=min_confidence,
|
"""
|
||||||
limit=limit,
|
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 {
|
return {
|
||||||
"memories": [
|
"memories": [
|
||||||
{
|
{
|
||||||
@@ -369,11 +383,14 @@ def api_get_memories(
|
|||||||
"project": m.project,
|
"project": m.project,
|
||||||
"confidence": m.confidence,
|
"confidence": m.confidence,
|
||||||
"status": m.status,
|
"status": m.status,
|
||||||
|
"reference_count": m.reference_count,
|
||||||
|
"last_referenced_at": m.last_referenced_at,
|
||||||
"updated_at": m.updated_at,
|
"updated_at": m.updated_at,
|
||||||
}
|
}
|
||||||
for m in memories
|
for m in memories
|
||||||
],
|
],
|
||||||
"types": MEMORY_TYPES,
|
"types": MEMORY_TYPES,
|
||||||
|
"statuses": MEMORY_STATUSES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -403,6 +420,33 @@ def api_invalidate_memory(memory_id: str) -> dict:
|
|||||||
return {"status": "invalidated", "id": memory_id}
|
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")
|
@router.post("/project/state")
|
||||||
def api_set_project_state(req: ProjectStateSetRequest) -> dict:
|
def api_set_project_state(req: ProjectStateSetRequest) -> dict:
|
||||||
"""Set or update a trusted project state entry."""
|
"""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")
|
@router.get("/interactions")
|
||||||
def api_list_interactions(
|
def api_list_interactions(
|
||||||
project: str | None = None,
|
project: str | None = None,
|
||||||
|
|||||||
229
src/atocore/memory/extractor.py
Normal file
229
src/atocore/memory/extractor.py
Normal file
@@ -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 <X>`` / ``the user prefers <X>``
|
||||||
|
* ``decided to <X>``
|
||||||
|
* ``<X> is a requirement`` / ``requirement: <X>``
|
||||||
|
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<value>.+?)$",
|
||||||
|
re.IGNORECASE | re.MULTILINE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"constraint_heading",
|
||||||
|
"project",
|
||||||
|
re.compile(
|
||||||
|
r"^[ \t]*#{1,6}[ \t]*constraint[ \t]*[:\-\u2014][ \t]*(?P<value>.+?)$",
|
||||||
|
re.IGNORECASE | re.MULTILINE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"requirement_heading",
|
||||||
|
"project",
|
||||||
|
re.compile(
|
||||||
|
r"^[ \t]*#{1,6}[ \t]*requirement[ \t]*[:\-\u2014][ \t]*(?P<value>.+?)$",
|
||||||
|
re.IGNORECASE | re.MULTILINE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fact_heading",
|
||||||
|
"knowledge",
|
||||||
|
re.compile(
|
||||||
|
r"^[ \t]*#{1,6}[ \t]*fact[ \t]*[:\-\u2014][ \t]*(?P<value>.+?)$",
|
||||||
|
re.IGNORECASE | re.MULTILINE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"preference_sentence",
|
||||||
|
"preference",
|
||||||
|
re.compile(
|
||||||
|
r"(?:^|[\s\.])(?:I|the user)\s+prefer(?:s)?\s+(?P<value>[^\n\.\!]{6,200})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"decided_to_sentence",
|
||||||
|
"adaptation",
|
||||||
|
re.compile(
|
||||||
|
r"(?:^|[\s\.])(?:I|we|the user)\s+decided\s+to\s+(?P<value>[^\n\.\!]{6,200})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"requirement_sentence",
|
||||||
|
"project",
|
||||||
|
re.compile(
|
||||||
|
r"(?:^|[\s\.])(?:the[ \t]+)?requirement\s+(?:is|was)\s+(?P<value>[^\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
|
||||||
374
tests/test_extractor.py
Normal file
374
tests/test_extractor.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user