everalgo-user-memory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- everalgo/user_memory/__init__.py +28 -0
- everalgo/user_memory/_render.py +28 -0
- everalgo/user_memory/atomic_fact.py +158 -0
- everalgo/user_memory/boundary.py +57 -0
- everalgo/user_memory/episode.py +159 -0
- everalgo/user_memory/foresight.py +227 -0
- everalgo/user_memory/profile.py +326 -0
- everalgo/user_memory/prompts/__init__.py +5 -0
- everalgo/user_memory/prompts/en/__init__.py +1 -0
- everalgo/user_memory/prompts/en/atomic_fact.py +88 -0
- everalgo/user_memory/prompts/en/episode.py +189 -0
- everalgo/user_memory/prompts/en/foresight.py +156 -0
- everalgo/user_memory/prompts/en/profile.py +217 -0
- everalgo/user_memory/prompts/zh/__init__.py +1 -0
- everalgo/user_memory/prompts/zh/atomic_fact.py +85 -0
- everalgo/user_memory/prompts/zh/episode.py +189 -0
- everalgo/user_memory/prompts/zh/foresight.py +148 -0
- everalgo/user_memory/prompts/zh/profile.py +196 -0
- everalgo/user_memory/py.typed +0 -0
- everalgo_user_memory-0.1.0.dist-info/METADATA +173 -0
- everalgo_user_memory-0.1.0.dist-info/RECORD +23 -0
- everalgo_user_memory-0.1.0.dist-info/WHEEL +4 -0
- everalgo_user_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""User-side memory extractors — 4 Extractors + boundary facade + boundary re-exports.
|
|
2
|
+
|
|
3
|
+
The user-scenario boundary facade (:class:`BoundaryDetector`) lives here; agent-trajectory
|
|
4
|
+
boundary detection will be added to ``everalgo.agent_memory`` in Stage 4.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from everalgo.boundary import DetectionResult
|
|
10
|
+
from everalgo.boundary.workspace import WorkspaceMemCellExtractor
|
|
11
|
+
from everalgo.user_memory.atomic_fact import AtomicFactExtractor
|
|
12
|
+
from everalgo.user_memory.boundary import BoundaryDetector
|
|
13
|
+
from everalgo.user_memory.episode import EpisodeExtractor
|
|
14
|
+
from everalgo.user_memory.foresight import ForesightExtractor
|
|
15
|
+
from everalgo.user_memory.profile import ProfileExtractor
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AtomicFactExtractor",
|
|
19
|
+
"BoundaryDetector",
|
|
20
|
+
"DetectionResult",
|
|
21
|
+
"EpisodeExtractor",
|
|
22
|
+
"ForesightExtractor",
|
|
23
|
+
"ProfileExtractor",
|
|
24
|
+
"WorkspaceMemCellExtractor",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Library logging setup (ADR-013): NullHandler on each subpackage logger.
|
|
28
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Shared rendering helpers for user-memory extractors.
|
|
2
|
+
|
|
3
|
+
This module is internal (``_render``-prefixed) — not part of the public API of ``everalgo.user_memory``.
|
|
4
|
+
``render_content`` is re-exported from ``everalgo.types._render`` (moved to core in Stage 4 so that
|
|
5
|
+
``everalgo-agent-memory`` can import it without creating a cross-package dependency on user-memory).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from everalgo.types._render import render_content
|
|
13
|
+
from everalgo.types.chat import ChatMessage
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from everalgo.types.conversation import MemCell
|
|
17
|
+
|
|
18
|
+
__all__ = ["chat_messages", "render_content"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def chat_messages(memcell: MemCell) -> list[ChatMessage]:
|
|
22
|
+
"""Filter ``memcell.items`` to ``ChatMessage`` entries only.
|
|
23
|
+
|
|
24
|
+
User-memory extractors silently skip ``ToolCallRequest`` / ``ToolCallResult`` — this is an
|
|
25
|
+
explicit contract supporting the agent → user-memory pipeline (see EverAlgo AGENTS.md). The
|
|
26
|
+
caller need not pre-filter; an AgentMemCell-shaped ``MemCell`` is acceptable input.
|
|
27
|
+
"""
|
|
28
|
+
return [item for item in memcell.items if isinstance(item, ChatMessage)]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Extract atomic facts (single verifiable assertions) from a conversation slice."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
9
|
+
|
|
10
|
+
from asgiref.sync import async_to_sync
|
|
11
|
+
|
|
12
|
+
from everalgo.llm.format import format_natural_language_time
|
|
13
|
+
from everalgo.llm.parse import parse_llm_json_object
|
|
14
|
+
from everalgo.llm.types import ChatMessage as LLMChatMessage
|
|
15
|
+
from everalgo.prompts import render_prompt
|
|
16
|
+
from everalgo.types import AtomicFact, MemCell
|
|
17
|
+
from everalgo.user_memory._render import chat_messages, render_content
|
|
18
|
+
from everalgo.user_memory.prompts.en.atomic_fact import ATOMIC_FACT_PROMPT
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from everalgo.llm.protocols import LLMClient
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AtomicFactExtractor:
|
|
27
|
+
"""Extract zero or more atomic facts from one MemCell.
|
|
28
|
+
|
|
29
|
+
Non-ChatMessage items in memcell.items are silently skipped (agent → user-memory contract).
|
|
30
|
+
Each string in the LLM ``atomic_facts.atomic_fact`` list becomes one :class:`AtomicFact` entity.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, *, llm: LLMClient) -> None:
|
|
34
|
+
self._llm = llm
|
|
35
|
+
|
|
36
|
+
async def aextract(
|
|
37
|
+
self,
|
|
38
|
+
memcell: MemCell,
|
|
39
|
+
*,
|
|
40
|
+
sender_id: str | None,
|
|
41
|
+
prompt: str | None = None,
|
|
42
|
+
) -> list[AtomicFact]:
|
|
43
|
+
"""Extract atomic facts for ``sender_id`` from ``memcell``.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
memcell: Source slice from boundary detection.
|
|
47
|
+
sender_id: Owner tag stamped on each resulting AtomicFact; pass ``None`` for generic
|
|
48
|
+
(whole-memcell) facts that do not bind to any user. The prompt itself does not
|
|
49
|
+
consume sender_id.
|
|
50
|
+
prompt: Prompt override; ``None`` uses the bundled default.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
LLMError: From the LLM call.
|
|
54
|
+
json.JSONDecodeError: If all parse strategies fail.
|
|
55
|
+
ValueError: On schema validation failure (missing required fields or empty list).
|
|
56
|
+
"""
|
|
57
|
+
rendered = render_prompt(
|
|
58
|
+
ATOMIC_FACT_PROMPT,
|
|
59
|
+
prompt,
|
|
60
|
+
INPUT_TEXT=_render_input_text(memcell),
|
|
61
|
+
TIME=_format_time_label(memcell.timestamp),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
response = await self._llm.chat(
|
|
65
|
+
messages=[LLMChatMessage(role="user", content=rendered)],
|
|
66
|
+
response_format={"type": "json_object"},
|
|
67
|
+
)
|
|
68
|
+
data = _parse_llm_response(response.content)
|
|
69
|
+
atomic_facts_block = _validate_atomic_facts(data)
|
|
70
|
+
return _build_atomic_facts(atomic_facts_block, sender_id=sender_id, memcell=memcell)
|
|
71
|
+
|
|
72
|
+
extract = async_to_sync(aextract)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Module-level helpers.
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _render_input_text(memcell: MemCell) -> str:
|
|
79
|
+
"""Render ChatMessage items as the raw conversation transcript for the ``{INPUT_TEXT}`` placeholder."""
|
|
80
|
+
lines: list[str] = []
|
|
81
|
+
for m in chat_messages(memcell):
|
|
82
|
+
text = render_content(m.content)
|
|
83
|
+
if not text:
|
|
84
|
+
continue
|
|
85
|
+
speaker = m.sender_name or m.sender_id
|
|
86
|
+
lines.append(f"{speaker}: {text}")
|
|
87
|
+
return "\n".join(lines)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _format_time_label(timestamp_ms: int) -> str:
|
|
91
|
+
"""Render timestamp as ``March 10, 2024 (Sunday) at 2:00 PM UTC``."""
|
|
92
|
+
return format_natural_language_time(timestamp_ms)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_llm_response(raw: str) -> object:
|
|
96
|
+
"""Parse LLM JSON response.
|
|
97
|
+
|
|
98
|
+
Schema-specific regex is tried first as a main-path optimisation (targets the nested
|
|
99
|
+
``{"atomic_facts": {"time": ..., "atomic_fact": [...]}}`` shape); falls back to the shared
|
|
100
|
+
three-tier parser (fence → direct loads → outermost braces).
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If all strategies fail to find a valid JSON object.
|
|
104
|
+
"""
|
|
105
|
+
match = re.search(
|
|
106
|
+
r'\{[^{}]*"atomic_facts"[^{}]*\{[^{}]*"time"[^{}]*"atomic_fact"[^{}]*\}[^{}]*\}',
|
|
107
|
+
raw,
|
|
108
|
+
re.DOTALL,
|
|
109
|
+
)
|
|
110
|
+
if match:
|
|
111
|
+
try:
|
|
112
|
+
return json.loads(match.group())
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
pass
|
|
115
|
+
return parse_llm_json_object(raw)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _validate_atomic_facts(data: object) -> dict[str, Any]:
|
|
119
|
+
"""Validate ``atomic_facts`` schema; raise :class:`ValueError` on any violation."""
|
|
120
|
+
if not isinstance(data, dict):
|
|
121
|
+
raise ValueError("LLM response is not a JSON object") # noqa: TRY004
|
|
122
|
+
data_dict = cast("dict[str, Any]", data)
|
|
123
|
+
block_raw = data_dict.get("atomic_facts")
|
|
124
|
+
if not isinstance(block_raw, dict):
|
|
125
|
+
raise ValueError("Missing 'atomic_facts' field in LLM response") # noqa: TRY004
|
|
126
|
+
block = cast("dict[str, Any]", block_raw)
|
|
127
|
+
if "time" not in block or not block["time"]:
|
|
128
|
+
raise ValueError("Missing time field in atomic_facts")
|
|
129
|
+
if "atomic_fact" not in block:
|
|
130
|
+
raise ValueError("Missing atomic_fact field in atomic_facts")
|
|
131
|
+
atomic_fact_raw = block["atomic_fact"]
|
|
132
|
+
if not isinstance(atomic_fact_raw, list):
|
|
133
|
+
raise ValueError(f"atomic_fact is not a list: {type(atomic_fact_raw)}") # noqa: TRY004
|
|
134
|
+
atomic_fact_list = cast("list[object]", atomic_fact_raw)
|
|
135
|
+
if not atomic_fact_list:
|
|
136
|
+
raise ValueError("atomic_fact list is empty")
|
|
137
|
+
return block
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _build_atomic_facts(block: dict[str, Any], *, sender_id: str | None, memcell: MemCell) -> list[AtomicFact]:
|
|
141
|
+
"""Split ``atomic_facts.atomic_fact`` list into individual AtomicFact entities."""
|
|
142
|
+
time_label = block["time"] if isinstance(block.get("time"), str) else _format_time_label(memcell.timestamp)
|
|
143
|
+
facts_list = cast("list[object]", block["atomic_fact"])
|
|
144
|
+
out: list[AtomicFact] = []
|
|
145
|
+
for item in facts_list:
|
|
146
|
+
if not isinstance(item, str) or not item.strip():
|
|
147
|
+
continue
|
|
148
|
+
out.append(
|
|
149
|
+
AtomicFact.model_validate(
|
|
150
|
+
{
|
|
151
|
+
"owner_id": sender_id,
|
|
152
|
+
"fact": item.strip(),
|
|
153
|
+
"timestamp": memcell.timestamp,
|
|
154
|
+
"time_label": time_label,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return out
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""User-scenario boundary detection facade.
|
|
2
|
+
|
|
3
|
+
Thin wrapper around the low-level ``everalgo.boundary.detect_boundaries`` primitive. Accepts
|
|
4
|
+
``list[ChatMessage]`` (user-scenario path; agent trajectories with tool calls go through
|
|
5
|
+
``everalgo.agent_memory.AgentBoundaryDetector`` instead).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from asgiref.sync import async_to_sync
|
|
13
|
+
|
|
14
|
+
from everalgo.boundary import detect_boundaries
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from everalgo.boundary import DetectionResult
|
|
18
|
+
from everalgo.llm.protocols import LLMClient
|
|
19
|
+
from everalgo.types import ChatMessage
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BoundaryDetector:
|
|
23
|
+
"""Boundary detection on a list of ``ChatMessage``. Native-async with sync bridge.
|
|
24
|
+
|
|
25
|
+
Calls ``everalgo.boundary.detect_boundaries`` directly — no extra state, no extra logic.
|
|
26
|
+
Exists so user-scenario callers have a class-style facade consistent with the rest of the
|
|
27
|
+
EverAlgo facade surface.
|
|
28
|
+
|
|
29
|
+
The LLM client is bound to the instance at construction time.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *, llm: LLMClient) -> None:
|
|
33
|
+
self._llm = llm
|
|
34
|
+
|
|
35
|
+
async def adetect(
|
|
36
|
+
self,
|
|
37
|
+
messages: list[ChatMessage],
|
|
38
|
+
*,
|
|
39
|
+
is_final: bool = False,
|
|
40
|
+
prompt: str | None = None,
|
|
41
|
+
) -> DetectionResult:
|
|
42
|
+
"""Detect conversation boundaries in a list of ``ChatMessage``.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
messages: Ordered list of chat messages to split into :class:`~everalgo.types.MemCell` slices.
|
|
46
|
+
is_final: When ``True``, treat the message stream as complete — the tail is flushed into the
|
|
47
|
+
last cell rather than held back as a pending partial window.
|
|
48
|
+
prompt: Optional prompt template override passed through to ``detect_boundaries``.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Named tuple ``(cells, tail)`` — ``cells`` contains completed :class:`~everalgo.types.MemCell`
|
|
52
|
+
slices; ``tail`` carries any unconfirmed trailing messages.
|
|
53
|
+
"""
|
|
54
|
+
return await detect_boundaries(messages, llm=self._llm, is_final=is_final, prompt=prompt)
|
|
55
|
+
|
|
56
|
+
detect = async_to_sync(adetect)
|
|
57
|
+
"""Sync bridge — only callable from non-event-loop contexts."""
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Extract a single Episode for one sender from a MemCell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
8
|
+
|
|
9
|
+
from asgiref.sync import async_to_sync
|
|
10
|
+
|
|
11
|
+
from everalgo.llm.format import format_message_timestamp, format_natural_language_time
|
|
12
|
+
from everalgo.llm.parse import parse_llm_json_object
|
|
13
|
+
from everalgo.llm.types import ChatMessage as LLMChatMessage
|
|
14
|
+
from everalgo.prompts import render_prompt
|
|
15
|
+
from everalgo.types import Episode, MemCell
|
|
16
|
+
from everalgo.user_memory._render import chat_messages, render_content
|
|
17
|
+
from everalgo.user_memory.prompts.en.episode import (
|
|
18
|
+
DEFAULT_CUSTOM_INSTRUCTIONS,
|
|
19
|
+
EPISODE_GENERATION_PROMPT,
|
|
20
|
+
USER_EPISODE_GENERATION_PROMPT,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from everalgo.llm.protocols import LLMClient
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EpisodeExtractor:
|
|
28
|
+
"""Extract one Episode for a given sender from a MemCell.
|
|
29
|
+
|
|
30
|
+
Non-ChatMessage items in memcell.items are silently skipped (agent → user-memory contract).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, *, llm: LLMClient) -> None:
|
|
34
|
+
self._llm = llm
|
|
35
|
+
|
|
36
|
+
async def aextract(
|
|
37
|
+
self,
|
|
38
|
+
memcell: MemCell,
|
|
39
|
+
*,
|
|
40
|
+
sender_id: str | None,
|
|
41
|
+
prompt: str | None = None,
|
|
42
|
+
custom_instructions: str | None = None,
|
|
43
|
+
) -> Episode:
|
|
44
|
+
"""Extract one Episode from ``memcell``.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
memcell: Source slice from boundary detection.
|
|
48
|
+
sender_id: Specific chat sender to centre the episode on (uses USER_EPISODE_GENERATION_PROMPT);
|
|
49
|
+
pass ``None`` to extract one whole-memcell generic episode (uses EPISODE_GENERATION_PROMPT)
|
|
50
|
+
— cheaper than per-user fan-out.
|
|
51
|
+
prompt: Prompt override; ``None`` uses the bundled default.
|
|
52
|
+
custom_instructions: Extra instruction block appended to the system prompt; ``None`` uses the default.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
LLMError: From the LLM call.
|
|
56
|
+
json.JSONDecodeError: If all parse strategies fail.
|
|
57
|
+
ValueError: If the LLM response is missing a non-empty ``title`` or ``content``.
|
|
58
|
+
"""
|
|
59
|
+
custom_instr = custom_instructions or DEFAULT_CUSTOM_INSTRUCTIONS
|
|
60
|
+
conv_start = _format_conversation_start_time(memcell.timestamp)
|
|
61
|
+
conversation = _render_conversation(memcell)
|
|
62
|
+
|
|
63
|
+
if sender_id is None:
|
|
64
|
+
rendered = render_prompt(
|
|
65
|
+
EPISODE_GENERATION_PROMPT,
|
|
66
|
+
prompt,
|
|
67
|
+
conversation_start_time=conv_start,
|
|
68
|
+
conversation=conversation,
|
|
69
|
+
custom_instructions=custom_instr,
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
user_name = _resolve_user_name(memcell, sender_id)
|
|
73
|
+
rendered = render_prompt(
|
|
74
|
+
USER_EPISODE_GENERATION_PROMPT,
|
|
75
|
+
prompt,
|
|
76
|
+
conversation_start_time=conv_start,
|
|
77
|
+
conversation=conversation,
|
|
78
|
+
custom_instructions=custom_instr,
|
|
79
|
+
user_name=user_name,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
response = await self._llm.chat(
|
|
83
|
+
messages=[LLMChatMessage(role="user", content=rendered)],
|
|
84
|
+
response_format={"type": "json_object"},
|
|
85
|
+
)
|
|
86
|
+
data = _parse_llm_response(response.content)
|
|
87
|
+
if "title" not in data or not data["title"]:
|
|
88
|
+
raise ValueError("LLM response missing title field")
|
|
89
|
+
if "content" not in data or not data["content"]:
|
|
90
|
+
raise ValueError("LLM response missing content field")
|
|
91
|
+
|
|
92
|
+
return _build_episode(data, sender_id=sender_id, memcell=memcell)
|
|
93
|
+
|
|
94
|
+
extract = async_to_sync(aextract)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Module-level helpers.
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_user_name(memcell: MemCell, sender_id: str) -> str:
|
|
101
|
+
"""Look up ``sender_id``'s ``sender_name`` from ChatMessage items; fall back to ``sender_id`` literal."""
|
|
102
|
+
for m in chat_messages(memcell):
|
|
103
|
+
if m.sender_id == sender_id and m.sender_name:
|
|
104
|
+
return m.sender_name
|
|
105
|
+
return sender_id
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _build_episode(data: dict[str, Any], *, sender_id: str | None, memcell: MemCell) -> Episode:
|
|
109
|
+
"""Assemble an :class:`Episode` from the parsed LLM payload and memcell metadata."""
|
|
110
|
+
title = cast("str", data["title"])
|
|
111
|
+
content = cast("str", data["content"])
|
|
112
|
+
summary_raw = data.get("summary")
|
|
113
|
+
summary = summary_raw if isinstance(summary_raw, str) and summary_raw.strip() else content[:200]
|
|
114
|
+
return Episode.model_validate(
|
|
115
|
+
{
|
|
116
|
+
"owner_id": sender_id,
|
|
117
|
+
"episode": content,
|
|
118
|
+
"subject": title,
|
|
119
|
+
"timestamp": memcell.timestamp,
|
|
120
|
+
"summary": summary, # preserved via extra='allow' without a schema bump
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _format_conversation_start_time(timestamp_ms: int) -> str:
|
|
126
|
+
"""Render the MemCell timestamp as ``March 14, 2024 (Thursday) at 3:00 PM UTC``."""
|
|
127
|
+
return format_natural_language_time(timestamp_ms)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _render_conversation(memcell: MemCell) -> str:
|
|
131
|
+
"""Render ChatMessage items as ``[YYYY-MM-DDTHH:MM:SSZ] speaker: content`` lines."""
|
|
132
|
+
lines: list[str] = []
|
|
133
|
+
for m in chat_messages(memcell):
|
|
134
|
+
text = render_content(m.content)
|
|
135
|
+
if not text:
|
|
136
|
+
continue
|
|
137
|
+
speaker = m.sender_name or m.sender_id
|
|
138
|
+
time_str = format_message_timestamp(m.timestamp)
|
|
139
|
+
lines.append(f"[{time_str}] {speaker}: {text}")
|
|
140
|
+
return "\n".join(lines)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_llm_response(raw: str) -> dict[str, Any]:
|
|
144
|
+
"""Parse LLM JSON response.
|
|
145
|
+
|
|
146
|
+
Schema-specific regex is tried first as a main-path optimisation (targets in-prose
|
|
147
|
+
``{"title": ..., "content": ...}`` fragments); falls back to the shared three-tier parser
|
|
148
|
+
(fence → direct loads → outermost braces).
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValueError: If all strategies fail.
|
|
152
|
+
"""
|
|
153
|
+
match = re.search(r'\{[^{}]*"title"[^{}]*"content"[^{}]*\}', raw, re.DOTALL)
|
|
154
|
+
if match:
|
|
155
|
+
try:
|
|
156
|
+
return cast("dict[str, Any]", json.loads(match.group()))
|
|
157
|
+
except json.JSONDecodeError:
|
|
158
|
+
pass
|
|
159
|
+
return parse_llm_json_object(raw)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Extract anticipated commitments (Foresight) from a conversation slice."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
10
|
+
|
|
11
|
+
from asgiref.sync import async_to_sync
|
|
12
|
+
|
|
13
|
+
from everalgo.llm.format import format_message_timestamp
|
|
14
|
+
from everalgo.llm.types import ChatMessage as LLMChatMessage
|
|
15
|
+
from everalgo.prompts import render_prompt
|
|
16
|
+
from everalgo.types import Foresight, MemCell
|
|
17
|
+
from everalgo.user_memory._render import chat_messages, render_content
|
|
18
|
+
from everalgo.user_memory.prompts.en.foresight import FORESIGHT_GENERATION_PROMPT
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from everalgo.llm.protocols import LLMClient
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_FORESIGHT_TEMPERATURE = 0.3
|
|
26
|
+
_FORESIGHT_MAX_COUNT = 10
|
|
27
|
+
_FORESIGHT_MIN_COUNT = 4 # warn-only floor; LLM may legitimately produce fewer
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ForesightExtractor:
|
|
31
|
+
"""Extract zero or more foresights from one MemCell.
|
|
32
|
+
|
|
33
|
+
Non-ChatMessage items in memcell.items are silently skipped (agent → user-memory contract).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, *, llm: LLMClient) -> None:
|
|
37
|
+
self._llm = llm
|
|
38
|
+
|
|
39
|
+
async def aextract(
|
|
40
|
+
self,
|
|
41
|
+
memcell: MemCell,
|
|
42
|
+
*,
|
|
43
|
+
sender_id: str,
|
|
44
|
+
prompt: str | None = None,
|
|
45
|
+
) -> list[Foresight]:
|
|
46
|
+
"""Extract foresights for ``sender_id`` from ``memcell``.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
memcell: Source slice from boundary detection.
|
|
50
|
+
sender_id: Must be one of memcell's chat senders; not inferred.
|
|
51
|
+
prompt: Prompt override; ``None`` uses the bundled default.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
LLMError: From the LLM call.
|
|
55
|
+
json.JSONDecodeError: On unparseable response.
|
|
56
|
+
"""
|
|
57
|
+
user_name = _resolve_user_name(memcell, sender_id)
|
|
58
|
+
rendered = render_prompt(
|
|
59
|
+
FORESIGHT_GENERATION_PROMPT,
|
|
60
|
+
prompt,
|
|
61
|
+
USER_ID=sender_id,
|
|
62
|
+
USER_NAME=user_name,
|
|
63
|
+
CONVERSATION_TEXT=_render_conversation(memcell),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
start_time_fallback = _format_start_time_from_timestamp(memcell.timestamp)
|
|
67
|
+
|
|
68
|
+
response = await self._llm.chat(
|
|
69
|
+
messages=[LLMChatMessage(role="user", content=rendered)],
|
|
70
|
+
response_format={"type": "json_object"},
|
|
71
|
+
temperature=_FORESIGHT_TEMPERATURE,
|
|
72
|
+
)
|
|
73
|
+
foresights = _parse_and_build_foresights(
|
|
74
|
+
response.content,
|
|
75
|
+
memcell=memcell,
|
|
76
|
+
sender_id=sender_id,
|
|
77
|
+
start_time_fallback=start_time_fallback,
|
|
78
|
+
)
|
|
79
|
+
if len(foresights) > _FORESIGHT_MAX_COUNT:
|
|
80
|
+
foresights = foresights[:_FORESIGHT_MAX_COUNT]
|
|
81
|
+
elif 0 < len(foresights) < _FORESIGHT_MIN_COUNT:
|
|
82
|
+
logger.warning("foresight count below soft floor: %d", len(foresights))
|
|
83
|
+
return foresights
|
|
84
|
+
|
|
85
|
+
extract = async_to_sync(aextract)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Module-level helpers.
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_user_name(memcell: MemCell, sender_id: str) -> str:
|
|
92
|
+
"""Look up ``sender_id``'s ``sender_name`` from ChatMessage items; fall back to ``sender_id`` literal."""
|
|
93
|
+
for m in chat_messages(memcell):
|
|
94
|
+
if m.sender_id == sender_id and m.sender_name:
|
|
95
|
+
return m.sender_name
|
|
96
|
+
return sender_id
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _render_conversation(memcell: MemCell) -> str:
|
|
100
|
+
"""Render ChatMessage items as ``[YYYY-MM-DDTHH:MM:SSZ] speaker: content`` lines."""
|
|
101
|
+
lines: list[str] = []
|
|
102
|
+
for m in chat_messages(memcell):
|
|
103
|
+
text = render_content(m.content)
|
|
104
|
+
if not text:
|
|
105
|
+
continue
|
|
106
|
+
speaker = m.sender_name or m.sender_id
|
|
107
|
+
time_str = format_message_timestamp(m.timestamp)
|
|
108
|
+
lines.append(f"[{time_str}] {speaker}: {text}")
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _format_start_time_from_timestamp(timestamp_ms: int) -> str:
|
|
113
|
+
"""Render MemCell timestamp as ``YYYY-MM-DD`` for foresight start_time fallback."""
|
|
114
|
+
return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC).strftime("%Y-%m-%d")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _clean_date_string(date_str: object) -> str | None:
|
|
118
|
+
"""Normalize to ``YYYY-MM-DD`` — keep digits + hyphens, validate regex + constructibility. Returns ``None`` if invalid."""
|
|
119
|
+
if not isinstance(date_str, str) or not date_str:
|
|
120
|
+
return None
|
|
121
|
+
cleaned = re.sub(r"[^\d\-]", "", date_str)
|
|
122
|
+
if not re.match(r"^\d{4}-\d{2}-\d{2}$", cleaned):
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
year, month, day = map(int, cleaned.split("-"))
|
|
126
|
+
datetime(year, month, day, tzinfo=UTC)
|
|
127
|
+
except ValueError:
|
|
128
|
+
return None
|
|
129
|
+
return cleaned
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _calculate_end_time_from_duration(start_time: str, duration_days: int) -> str | None:
|
|
133
|
+
"""Compute ``end_time = start_time + duration_days`` in ``YYYY-MM-DD``."""
|
|
134
|
+
try:
|
|
135
|
+
start_date = datetime.strptime(start_time, "%Y-%m-%d").replace(tzinfo=UTC)
|
|
136
|
+
end_date = start_date + timedelta(days=duration_days)
|
|
137
|
+
except ValueError:
|
|
138
|
+
return None
|
|
139
|
+
return end_date.strftime("%Y-%m-%d")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _calculate_duration_days(start_time: str, end_time: str) -> int | None:
|
|
143
|
+
"""Compute ``end_time - start_time`` in days."""
|
|
144
|
+
try:
|
|
145
|
+
start_date = datetime.strptime(start_time, "%Y-%m-%d").replace(tzinfo=UTC)
|
|
146
|
+
end_date = datetime.strptime(end_time, "%Y-%m-%d").replace(tzinfo=UTC)
|
|
147
|
+
except ValueError:
|
|
148
|
+
return None
|
|
149
|
+
return (end_date - start_date).days
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_and_build_foresights(
|
|
153
|
+
raw: str,
|
|
154
|
+
*,
|
|
155
|
+
memcell: MemCell,
|
|
156
|
+
sender_id: str,
|
|
157
|
+
start_time_fallback: str,
|
|
158
|
+
) -> list[Foresight]:
|
|
159
|
+
"""Parse LLM foresight payload + apply date cleaning + mutual time computation.
|
|
160
|
+
|
|
161
|
+
Accepts top-level JSON array OR ``{"foresights": [...]}`` wrapped form.
|
|
162
|
+
"""
|
|
163
|
+
data = _parse_llm_response(raw)
|
|
164
|
+
|
|
165
|
+
items: list[Any]
|
|
166
|
+
if isinstance(data, list):
|
|
167
|
+
items = cast("list[Any]", data) # type: ignore[redundant-cast]
|
|
168
|
+
elif isinstance(data, dict):
|
|
169
|
+
wrapped = cast("dict[str, Any]", data).get("foresights")
|
|
170
|
+
if isinstance(wrapped, list):
|
|
171
|
+
items = cast("list[Any]", wrapped) # type: ignore[redundant-cast]
|
|
172
|
+
else:
|
|
173
|
+
return []
|
|
174
|
+
else:
|
|
175
|
+
return []
|
|
176
|
+
|
|
177
|
+
out: list[Foresight] = []
|
|
178
|
+
for raw_item in items:
|
|
179
|
+
if not isinstance(raw_item, dict):
|
|
180
|
+
continue
|
|
181
|
+
item = cast("dict[str, Any]", raw_item)
|
|
182
|
+
content = item.get("content")
|
|
183
|
+
if not isinstance(content, str) or not content.strip():
|
|
184
|
+
continue
|
|
185
|
+
evidence_raw = item.get("evidence", "")
|
|
186
|
+
evidence = evidence_raw if isinstance(evidence_raw, str) else ""
|
|
187
|
+
|
|
188
|
+
item_start_time = _clean_date_string(item.get("start_time")) or start_time_fallback
|
|
189
|
+
item_end_time = _clean_date_string(item.get("end_time"))
|
|
190
|
+
item_duration_days = item.get("duration_days") if isinstance(item.get("duration_days"), int) else None
|
|
191
|
+
|
|
192
|
+
# Mutual time computation
|
|
193
|
+
if item_start_time:
|
|
194
|
+
if item_duration_days is not None and not item_end_time:
|
|
195
|
+
item_end_time = _calculate_end_time_from_duration(item_start_time, item_duration_days)
|
|
196
|
+
elif item_end_time and item_duration_days is None:
|
|
197
|
+
item_duration_days = _calculate_duration_days(item_start_time, item_end_time)
|
|
198
|
+
|
|
199
|
+
out.append(
|
|
200
|
+
Foresight(
|
|
201
|
+
owner_id=sender_id,
|
|
202
|
+
foresight=content,
|
|
203
|
+
evidence=evidence,
|
|
204
|
+
timestamp=memcell.timestamp,
|
|
205
|
+
start_time=item_start_time,
|
|
206
|
+
end_time=item_end_time,
|
|
207
|
+
duration_days=item_duration_days,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parse_llm_response(raw: str) -> object:
|
|
214
|
+
r"""Parse LLM JSON response: `` ```json `` fence first, then direct ``json.loads``.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
json.JSONDecodeError: If both strategies fail.
|
|
218
|
+
"""
|
|
219
|
+
if "```json" in raw:
|
|
220
|
+
start = raw.find("```json") + 7
|
|
221
|
+
end = raw.find("```", start)
|
|
222
|
+
if end > start:
|
|
223
|
+
try:
|
|
224
|
+
return json.loads(raw[start:end].strip())
|
|
225
|
+
except json.JSONDecodeError:
|
|
226
|
+
pass
|
|
227
|
+
return json.loads(raw)
|