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.
@@ -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)