Skip to content

Commit c07f561

Browse files
author
dev-pd
committed
Memory v2: LLM-proposed tags, 0-100% confidence buckets, provenance/citations; clean UI errors
1 parent c9a587e commit c07f561

16 files changed

Lines changed: 340 additions & 189 deletions

File tree

backend/app/agents/agent_b.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Agent B — the learning agent.
22
33
B is Agent A plus memory: the same shared pipeline and tools, but before each run
4-
it tags the page, retrieves matching playbook lessons, and injects them into a
5-
reserved slot on top of the unchanged core prompt. While learning on the train
6-
split it also carries the memory tools to jot observations into the diary.
4+
it injects the top trusted playbook rules into a reserved slot on top of the
5+
unchanged core prompt. While learning on the train split it also carries the
6+
memory tools to jot observations into the diary — choosing its own tags, shown the
7+
existing vocabulary so the janitor's grouping stays coherent.
78
89
Everything else is identical to A — that is what makes any score difference
910
attributable to memory.
@@ -13,8 +14,7 @@
1314

1415
from app.agents.blind_spot import scan_blind_spots
1516
from app.agents.pipeline import run_pipeline
16-
from app.agents.prompts import CORE_SYSTEM, LEARNING_NOTE, render_memory_slot
17-
from app.memory.tagging import tags_for
17+
from app.agents.prompts import CORE_SYSTEM, render_learning_note, render_memory_slot
1818
from app.models import AgentRunResult
1919
from app.repositories import CompletionRepository, RunRepository
2020
from app.repositories.diary import DiaryRepository
@@ -24,6 +24,8 @@
2424
from app.tools.memory_tools import ReadMemoryTool, WriteMemoryTool
2525
from app.tools.registry import build_default_registry
2626

27+
INJECT_TOP_RULES = 5
28+
2729

2830
async def run_agent_b(
2931
url: str,
@@ -44,22 +46,24 @@ async def run_agent_b(
4446
diary = diary or DiaryRepository()
4547
playbook = playbook or PlaybookRepository()
4648

47-
# Tag the page (fetch once; a cheap repo read in replay) to drive retrieval.
49+
# Fetch the page once (cheap repo read in replay) — needed for the blind-spot scan.
4850
fetch_tool = base.get("fetch_page")
4951
assert isinstance(fetch_tool, FetchPageTool)
5052
page = await fetch_tool.run(FetchPageInput(url=url))
51-
tags = tags_for(keyword, page)
5253

53-
# Build B's prompt: core + (retrieved lessons) + (learning note).
5454
system = CORE_SYSTEM
5555
extra_tools = []
5656
if memory_on:
57-
entries = await playbook.get_by_tags(tags)
58-
system += render_memory_slot([e.rule for e in entries])
57+
top = await playbook.top(INJECT_TOP_RULES)
58+
system += render_memory_slot([e.rule for e in top])
5959
extra_tools.append(ReadMemoryTool(playbook=playbook))
6060
if learn:
61-
system += LEARNING_NOTE
62-
extra_tools.append(WriteMemoryTool(diary=diary, run=run_index, tags=tags))
61+
known_tags = {e.tag for e in await playbook.all()}
62+
known_tags.update(tag for entry in await diary.all() for tag in entry.tags)
63+
system += render_learning_note(sorted(known_tags))
64+
extra_tools.append(
65+
WriteMemoryTool(diary=diary, run=run_index, url=url, keyword=keyword)
66+
)
6367

6468
b_registry = ToolRegistry([*base.tools(), *extra_tools])
6569

backend/app/agents/prompts.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,20 @@ def render_memory_slot(lessons: list[str]) -> str:
5151
)
5252

5353

54-
# Shown to B only while learning on the train split.
55-
LEARNING_NOTE = (
56-
"\n\nYou are learning. When competitors reveal a pattern this page is missing, "
57-
"call write_memory with an abstract, UNCONDITIONAL rule for this kind of page "
58-
"that names the exact recommendation to make next time — phrased so it applies "
59-
"even when a future page has no competitor data. Use the shape: 'On <page kind>, "
60-
"always recommend <recommendation_type> (target <target>): <one-line why>.' "
61-
"Never tie it to this specific URL or keyword."
62-
)
54+
# Shown to B only while learning on the train split. The known tag vocabulary is
55+
# passed in so the LLM reuses tags (keeping the janitor's grouping coherent).
56+
def render_learning_note(known_tags: list[str]) -> str:
57+
vocab = ", ".join(known_tags) if known_tags else "(none yet — you may create tags)"
58+
return (
59+
"\n\nYou are learning. When competitors reveal a pattern this page is missing, "
60+
"call write_memory with an abstract, UNCONDITIONAL rule for this kind of page "
61+
"that names the exact recommendation to make next time — phrased so it applies "
62+
"even when a future page has no competitor data. Use the shape: 'On <page kind>, "
63+
"always recommend <recommendation_type> (target <target>): <one-line why>.' "
64+
f"Choose 1-4 tags for the pattern. Existing tags: {vocab}. Reuse an existing "
65+
"tag when it fits; only add a new tag for a genuinely new kind of pattern. "
66+
"Never tie a lesson to this specific URL or keyword."
67+
)
6368

6469

6570
# Per-stage focus. Each runs its own short ReAct loop.

backend/app/api/memory.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
"""Memory endpoints — a peek at what Agent B has learned."""
1+
"""Memory endpoints — a peek at what Agent B has learned, and why."""
22

33
from fastapi import APIRouter
44

5-
from app.models.memory import PlaybookEntry
5+
from app.models.memory import DiaryEntry, PlaybookEntry
6+
from app.repositories.diary import DiaryRepository
67
from app.repositories.playbook import PlaybookRepository
78

89
router = APIRouter(prefix="/memory", tags=["memory"])
@@ -11,3 +12,9 @@
1112
@router.get("/playbook", response_model=list[PlaybookEntry])
1213
async def playbook() -> list[PlaybookEntry]:
1314
return await PlaybookRepository().all()
15+
16+
17+
@router.get("/diary", response_model=list[DiaryEntry])
18+
async def diary() -> list[DiaryEntry]:
19+
"""The raw observations — used to show the provenance behind each rule."""
20+
return await DiaryRepository().all()

backend/app/memory/janitor.py

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,53 @@
11
"""The janitor — consolidate the diary into the playbook.
22
3-
Scores each tag's pattern by blending frequency, strength, and recency, and
4-
promotes the ones that clear a tunable bar from the messy diary into the clean
5-
playbook. The playbook is always *regenerated* from the diary (never edited in
6-
place), so it can be rebuilt if it ever goes bad, and it is bounded to a top-K by
7-
confidence. The outcome signal comes from B's own observations and their
8-
recurrence — never B grading itself.
3+
Scores each tag's pattern by blending frequency, strength, and recency into a
4+
0–100% confidence, and promotes the ones that clear a bar from the messy diary
5+
into the clean playbook. The playbook is always *regenerated* from the diary
6+
(never edited in place), bounded to a top-K by confidence. Each promoted rule
7+
cites the diary rows it was distilled from (provenance).
8+
9+
Tags are proposed by the LLM (reusing a shown vocabulary), so grouping by exact
10+
tag stays coherent without any clustering.
911
"""
1012

1113
from __future__ import annotations
1214

1315
from collections import defaultdict
1416

15-
from app.models.memory import DiaryEntry, PlaybookEntry
16-
from app.repositories.diary import DiaryRepository
17-
from app.repositories.playbook import PlaybookRepository
17+
from app.models.memory import ConfidenceBucket, DiaryEntry, PlaybookEntry, Strength
1818

1919
RECENCY_DECAY = 0.8 # weight of an observation one run older
20+
STRENGTH_POINTS: dict[Strength, int] = {"weak": 15, "medium": 30, "strong": 50}
2021
MIN_RUNS = 2 # a pattern must recur across >= 2 training runs to be trusted
21-
PROMOTION_BAR = 3.0 # min total confidence for a tag to promote at all
22-
TOP_RULES_PER_TAG = 3 # keep the strongest few distinct lessons per tag
22+
PROMOTION_CONFIDENCE = 40 # min 0-100 confidence to promote (the low/medium boundary)
23+
TOP_RULES_PER_TAG = 3
2324
TOP_K = 10 # bounded active playbook
2425

2526

26-
def _weight(entry: DiaryEntry, now: int) -> float:
27-
return entry.strength * (RECENCY_DECAY ** (now - entry.run))
27+
def _points(entry: DiaryEntry, now: int) -> float:
28+
return STRENGTH_POINTS[entry.strength] * (RECENCY_DECAY ** (now - entry.run))
29+
30+
31+
def confidence_of(entries: list[DiaryEntry], now: int) -> int:
32+
"""0–100% confidence from frequency × strength × recency (capped)."""
33+
return min(100, round(sum(_points(e, now) for e in entries)))
34+
35+
36+
def bucket_of(confidence: int) -> ConfidenceBucket:
37+
if confidence >= 70:
38+
return "high"
39+
if confidence >= 40:
40+
return "medium"
41+
return "low"
2842

2943

3044
def consolidate_entries(
3145
diary: list[DiaryEntry],
3246
*,
33-
bar: float = PROMOTION_BAR,
47+
bar: int = PROMOTION_CONFIDENCE,
3448
top_k: int = TOP_K,
3549
) -> list[PlaybookEntry]:
36-
"""Pure consolidation: diary entries -> the promoted, bounded playbook.
37-
38-
A tag is trusted when its page-kind recurred across >= MIN_RUNS training runs
39-
(that's the "proven pattern" gate — robust to the LLM phrasing lessons
40-
differently each time). Within a trusted tag we keep the strongest few
41-
distinct lessons by confidence (frequency x strength x recency).
42-
"""
50+
"""Pure consolidation: diary entries -> the promoted, bounded playbook."""
4351
if not diary:
4452
return []
4553
now = max(e.run for e in diary)
@@ -51,33 +59,33 @@ def consolidate_entries(
5159

5260
promoted: list[PlaybookEntry] = []
5361
for tag, entries in by_tag.items():
54-
runs = {e.run for e in entries}
55-
if len(runs) < MIN_RUNS:
62+
if len({e.run for e in entries}) < MIN_RUNS:
5663
continue # pattern hasn't recurred across runs yet
57-
if sum(_weight(e, now) for e in entries) < bar:
58-
continue
5964

60-
# Score each distinct lesson under this tag.
6165
by_lesson: dict[str, list[DiaryEntry]] = defaultdict(list)
6266
for entry in entries:
6367
by_lesson[entry.lesson.strip()].append(entry)
6468

6569
scored = sorted(
6670
(
67-
(sum(_weight(e, now) for e in les_entries), lesson, les_entries)
71+
(confidence_of(les_entries, now), lesson, les_entries)
6872
for lesson, les_entries in by_lesson.items()
6973
),
7074
reverse=True,
7175
key=lambda triple: triple[0],
7276
)
7377
for confidence, lesson, les_entries in scored[:TOP_RULES_PER_TAG]:
78+
if confidence < bar:
79+
continue
7480
promoted.append(
7581
PlaybookEntry(
7682
tag=tag,
7783
rule=lesson,
78-
confidence=round(confidence, 3),
84+
confidence=confidence,
85+
confidence_bucket=bucket_of(confidence),
7986
times_seen=len(les_entries),
8087
last_run=max(e.run for e in les_entries),
88+
source_ids=[e.id for e in les_entries if e.id],
8189
)
8290
)
8391

@@ -86,13 +94,12 @@ def consolidate_entries(
8694

8795

8896
async def run_janitor(
89-
diary_repo: DiaryRepository | None = None,
90-
playbook_repo: PlaybookRepository | None = None,
91-
*,
92-
bar: float = PROMOTION_BAR,
93-
top_k: int = TOP_K,
94-
) -> list[PlaybookEntry]:
97+
diary_repo=None, playbook_repo=None, *, bar=PROMOTION_CONFIDENCE, top_k=TOP_K
98+
):
9599
"""Read the diary, rebuild the playbook, write it atomically."""
100+
from app.repositories.diary import DiaryRepository
101+
from app.repositories.playbook import PlaybookRepository
102+
96103
diary_repo = diary_repo or DiaryRepository()
97104
playbook_repo = playbook_repo or PlaybookRepository()
98105
diary = await diary_repo.all()

backend/app/memory/tagging.py

Lines changed: 0 additions & 30 deletions
This file was deleted.

backend/app/models/memory.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,44 @@
22
33
`DiaryEntry` is the messy, append-only episodic log: raw tagged observations B
44
jots during a run. `PlaybookEntry` is the clean, trusted, bounded semantic layer:
5-
abstract rules promoted from the diary, and the only thing injected back into B's
5+
abstract rules promoted from the diary, the only thing injected back into B's
66
prompt.
77
8-
Both store *abstractions* (tagged patterns / rules), never answers glued to a
9-
specific URL or keyword — a lesson tied to specifics is a bug and would also trip
10-
the leak guard.
8+
Scoring is intuitive: each sighting has a **strength bucket** (weak/medium/strong),
9+
and a rule's **confidence is a 0–100%** score (frequency × strength × recency),
10+
itself bucketed low/medium/high. Both store *abstractions* (tagged patterns),
11+
never answers glued to a specific URL — a lesson tied to specifics is a bug.
1112
"""
1213

1314
from __future__ import annotations
1415

15-
from pydantic import BaseModel, Field
16+
from typing import Literal
17+
18+
from pydantic import BaseModel
19+
20+
Strength = Literal["weak", "medium", "strong"]
21+
ConfidenceBucket = Literal["low", "medium", "high"]
1622

1723

1824
class DiaryEntry(BaseModel):
1925
"""One raw observation B wrote during a run."""
2026

27+
id: str = "" # Mongo row id (set on read) — used for provenance/citations
2128
tags: list[str]
2229
lesson: str
23-
strength: int = Field(default=2, ge=1, le=3) # weak=1, medium=2, strong=3
30+
strength: Strength = "medium"
2431
run: int = 0 # training-run index, for recency
32+
url: str = "" # the page that prompted it (provenance only; never injected)
33+
keyword: str = ""
2534

2635

2736
class PlaybookEntry(BaseModel):
2837
"""A promoted, trusted rule. Tag-keyed; injected into B's prompt slot."""
2938

3039
tag: str
3140
rule: str
32-
confidence: float = 0.0
41+
confidence: int = 0 # 0–100%
42+
confidence_bucket: ConfidenceBucket = "low"
3343
times_seen: int = 0
3444
last_run: int = 0
45+
source_ids: list[str] = [] # diary rows this rule was distilled from (citations)

backend/app/repositories/diary.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@ class DiaryRepository(BaseRepository):
1010
collection_name = "diary"
1111

1212
async def append(self, entry: DiaryEntry) -> None:
13-
await self.collection.insert_one(entry.model_dump())
13+
# Let Mongo assign the row id; we read it back as `id` for provenance.
14+
await self.collection.insert_one(entry.model_dump(exclude={"id"}))
1415

1516
async def all(self) -> list[DiaryEntry]:
1617
docs = await self.collection.find({}).to_list(length=None)
17-
return [DiaryEntry.model_validate(doc) for doc in docs]
18+
entries: list[DiaryEntry] = []
19+
for doc in docs:
20+
doc["id"] = str(doc.pop("_id"))
21+
entries.append(DiaryEntry.model_validate(doc))
22+
return entries
1823

1924
async def clear(self) -> None:
2025
await self.collection.delete_many({})

backend/app/repositories/playbook.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ async def get_by_tags(self, tags: list[str], top_k: int = 5) -> list[PlaybookEnt
2727
entries.sort(key=lambda e: e.confidence, reverse=True)
2828
return entries[:top_k]
2929

30+
async def top(self, k: int = 5) -> list[PlaybookEntry]:
31+
"""The highest-confidence rules overall (for prompt injection)."""
32+
entries = await self.all()
33+
entries.sort(key=lambda e: e.confidence, reverse=True)
34+
return entries[:k]
35+
3036
async def all(self) -> list[PlaybookEntry]:
3137
docs = await self.collection.find({}).to_list(length=None)
3238
return [PlaybookEntry.model_validate(doc) for doc in docs]

0 commit comments

Comments
 (0)