Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81ed1d816f |
No files matched your search
@@ -68,22 +68,6 @@ Hy Memory and MemHub are versioned and released independently.
|
|||||||
`hy_node_permissions` proof table. Evidence, governed memories, model
|
`hy_node_permissions` proof table. Evidence, governed memories, model
|
||||||
profiles, SDK stores, and source/host grants are preserved.
|
profiles, SDK stores, and source/host grants are preserved.
|
||||||
|
|
||||||
## Hy Memory 2.0.7 — 2026-10-28
|
|
||||||
|
|
||||||
- Coding and working experiences and facts now keep their conversation time.
|
|
||||||
`add(..., memory_at=...)` reached only the chat path before; the
|
|
||||||
productivity path dropped it, so every experience had only its write time
|
|
||||||
and MemHub showed them as "时间未知" (time unknown). `coding_memory_meta`
|
|
||||||
and `working_fact` gain a nullable `memory_at` column (epoch seconds) in
|
|
||||||
both the working and coding stores; existing databases get it on open, and
|
|
||||||
their old rows stay `None`. Without `memory_at` a write uses the time the
|
|
||||||
request arrived, as chat does. When an experience is updated by a later
|
|
||||||
conversation it keeps the later of the two times.
|
|
||||||
- `list_memories` (`scene="coding"`, `"working"` and `"all"`), `get` and
|
|
||||||
`search` return `memory_at` for experiences and facts in the same field and
|
|
||||||
format as chat (epoch seconds, `None` when unknown). Experience search
|
|
||||||
results now also carry `created_at`.
|
|
||||||
|
|
||||||
## Hy Memory 2.0.6 — 2026-10-27
|
## Hy Memory 2.0.6 — 2026-10-27
|
||||||
|
|
||||||
- Published
|
- Published
|
||||||
|
|||||||
+1
-8
@@ -1544,7 +1544,6 @@ class HyMemoryClient:
|
|||||||
# LLM 写的"这个结论一路怎么改成现在这样",直接进提示词;
|
# LLM 写的"这个结论一路怎么改成现在这样",直接进提示词;
|
||||||
# 没被覆盖过就是空串。不参与检索——召回认的是 statement 的当前值。
|
# 没被覆盖过就是空串。不参与检索——召回认的是 statement 的当前值。
|
||||||
"fact_chain_summary": fact.fact_chain_summary,
|
"fact_chain_summary": fact.fact_chain_summary,
|
||||||
"memory_at": _epoch_seconds(fact.memory_at),
|
|
||||||
"created_at": fact.created_at.isoformat() if fact.created_at else None,
|
"created_at": fact.created_at.isoformat() if fact.created_at else None,
|
||||||
"score": round(float(score), 4),
|
"score": round(float(score), 4),
|
||||||
"source": fact.source,
|
"source": fact.source,
|
||||||
@@ -1653,8 +1652,6 @@ class HyMemoryClient:
|
|||||||
"score": (round(float(score_by_id[d.experience_id]), 4)
|
"score": (round(float(score_by_id[d.experience_id]), 4)
|
||||||
if d.experience_id in score_by_id else None),
|
if d.experience_id in score_by_id else None),
|
||||||
"confidence": d.confidence,
|
"confidence": d.confidence,
|
||||||
"memory_at": _epoch_seconds(d.memory_at),
|
|
||||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
|
||||||
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
||||||
"source": d.source,
|
"source": d.source,
|
||||||
"extraction_source": d.extraction_source,
|
"extraction_source": d.extraction_source,
|
||||||
@@ -1956,7 +1953,6 @@ class HyMemoryClient:
|
|||||||
"content" chat 正文;经验为 "task\nsolution";事实为 statement
|
"content" chat 正文;经验为 "task\nsolution";事实为 statement
|
||||||
"score" RRF 融合分;"lane_score" 为该路原始分;"lane_rank" 为该路名次
|
"score" RRF 融合分;"lane_score" 为该路原始分;"lane_rank" 为该路名次
|
||||||
"source", "created_at", "updated_at"(秒级 epoch,取不到为 None)
|
"source", "created_at", "updated_at"(秒级 epoch,取不到为 None)
|
||||||
"memory_at" 对话时间(秒级 epoch),各路原有字段,三路同名同格式
|
|
||||||
chat 路内部名次:normal → schema → proactive 依次排;working/coding 路
|
chat 路内部名次:normal → schema → proactive 依次排;working/coding 路
|
||||||
内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。
|
内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。
|
||||||
"""
|
"""
|
||||||
@@ -2148,8 +2144,7 @@ class HyMemoryClient:
|
|||||||
"limit", "offset", "request_id", "elapsed_ms"}
|
"limit", "offset", "request_id", "elapsed_ms"}
|
||||||
每条带 "scene"、"kind"(chat | experience)、"memory_id"、"content"
|
每条带 "scene"、"kind"(chat | experience)、"memory_id"、"content"
|
||||||
(经验为 "task\nsolution")、"source"、"status"、"created_at"、
|
(经验为 "task\nsolution")、"source"、"status"、"created_at"、
|
||||||
"updated_at"(秒级 epoch),以及该路原有字段(含对话时间
|
"updated_at"(秒级 epoch),以及该路原有字段。
|
||||||
"memory_at",秒级 epoch,三路同名同格式)。
|
|
||||||
"""
|
"""
|
||||||
return self._loop_thread.run(
|
return self._loop_thread.run(
|
||||||
self.async_list_memories(
|
self.async_list_memories(
|
||||||
@@ -2972,7 +2967,6 @@ class HyMemoryClient:
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
source=source,
|
source=source,
|
||||||
memory_at=memory_at,
|
|
||||||
)
|
)
|
||||||
# Public receipt always identifies the productivity namespace.
|
# Public receipt always identifies the productivity namespace.
|
||||||
resp["variant"] = scene_variant
|
resp["variant"] = scene_variant
|
||||||
@@ -5926,7 +5920,6 @@ class HyMemoryClient:
|
|||||||
"source_fact_ids": list(d.source_fact_ids),
|
"source_fact_ids": list(d.source_fact_ids),
|
||||||
"confidence": d.confidence,
|
"confidence": d.confidence,
|
||||||
"status": MemoryStatus.ACTIVE.value,
|
"status": MemoryStatus.ACTIVE.value,
|
||||||
"memory_at": _epoch_seconds(d.memory_at),
|
|
||||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||||
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
||||||
"source": d.source,
|
"source": d.source,
|
||||||
|
|||||||
@@ -454,10 +454,9 @@ class WorkingFactReconciler:
|
|||||||
workspace_id: Optional[str] = None,
|
workspace_id: Optional[str] = None,
|
||||||
branch: Optional[str] = None,
|
branch: Optional[str] = None,
|
||||||
source: str = "default",
|
source: str = "default",
|
||||||
memory_at: Optional[datetime] = None,
|
|
||||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||||
"""
|
"""
|
||||||
执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。
|
执行 reconcile + 持久化。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
|
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
|
||||||
@@ -494,7 +493,6 @@ class WorkingFactReconciler:
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
branch=branch,
|
branch=branch,
|
||||||
source=source,
|
source=source,
|
||||||
memory_at=memory_at,
|
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
|
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
|
||||||
@@ -783,7 +781,6 @@ class WorkingFactReconciler:
|
|||||||
workspace_id: Optional[str],
|
workspace_id: Optional[str],
|
||||||
branch: Optional[str] = None,
|
branch: Optional[str] = None,
|
||||||
source: str = "default",
|
source: str = "default",
|
||||||
memory_at: Optional[datetime] = None,
|
|
||||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||||
inserted: List[WorkingFact] = []
|
inserted: List[WorkingFact] = []
|
||||||
ops: List[FactReconcileOp] = []
|
ops: List[FactReconcileOp] = []
|
||||||
@@ -804,7 +801,6 @@ class WorkingFactReconciler:
|
|||||||
subject=draft.subject,
|
subject=draft.subject,
|
||||||
details=draft.details,
|
details=draft.details,
|
||||||
status="active",
|
status="active",
|
||||||
memory_at=memory_at,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
|
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
|
||||||
|
|||||||
@@ -577,7 +577,6 @@ class CodingMemoryReconciler:
|
|||||||
confidence=d.confidence,
|
confidence=d.confidence,
|
||||||
source=d.source,
|
source=d.source,
|
||||||
extraction_source=d.extraction_source,
|
extraction_source=d.extraction_source,
|
||||||
memory_at=d.memory_at,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ CREATE TABLE IF NOT EXISTS coding_memory_meta (
|
|||||||
confidence REAL NOT NULL DEFAULT 0.7,
|
confidence REAL NOT NULL DEFAULT 0.7,
|
||||||
source TEXT NOT NULL DEFAULT 'default',
|
source TEXT NOT NULL DEFAULT 'default',
|
||||||
extraction_source TEXT NOT NULL DEFAULT 'auto_extract',
|
extraction_source TEXT NOT NULL DEFAULT 'auto_extract',
|
||||||
memory_at INTEGER,
|
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
@@ -110,7 +109,6 @@ CREATE TABLE IF NOT EXISTS working_fact (
|
|||||||
derived_experience_ids TEXT NOT NULL DEFAULT '[]',
|
derived_experience_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
fact_chain_summary TEXT NOT NULL DEFAULT '',
|
fact_chain_summary TEXT NOT NULL DEFAULT '',
|
||||||
memory_at INTEGER,
|
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
-- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走
|
-- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走
|
||||||
@@ -166,8 +164,6 @@ _ADDED_COLUMNS = {
|
|||||||
"coding_memory_meta": {
|
"coding_memory_meta": {
|
||||||
"source_fact_ids": "TEXT NOT NULL DEFAULT '[]'",
|
"source_fact_ids": "TEXT NOT NULL DEFAULT '[]'",
|
||||||
"extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'",
|
"extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'",
|
||||||
# 对话时间,秒级 epoch(同 chat)。老库里的行是 NULL:那时没存过。
|
|
||||||
"memory_at": "INTEGER",
|
|
||||||
},
|
},
|
||||||
# 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着
|
# 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着
|
||||||
# fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。
|
# fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。
|
||||||
@@ -181,7 +177,6 @@ _ADDED_COLUMNS = {
|
|||||||
"subject": "TEXT NOT NULL DEFAULT ''",
|
"subject": "TEXT NOT NULL DEFAULT ''",
|
||||||
"details": "TEXT NOT NULL DEFAULT ''",
|
"details": "TEXT NOT NULL DEFAULT ''",
|
||||||
"source": "TEXT NOT NULL DEFAULT 'default'",
|
"source": "TEXT NOT NULL DEFAULT 'default'",
|
||||||
"memory_at": "INTEGER",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,29 +290,6 @@ def _parse_dt(s: Optional[str]) -> Optional[datetime]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _from_epoch(value: Any) -> Optional[datetime]:
|
|
||||||
"""memory_at 列(秒级 epoch)→ datetime;NULL 或坏值返回 None。"""
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return datetime.fromtimestamp(int(value))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _to_epoch(value: Optional[datetime]) -> Optional[int]:
|
|
||||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _later(a: Optional[datetime], b: Optional[datetime]) -> Optional[datetime]:
|
|
||||||
"""两个对话时间取较晚的;一边缺就取另一边。"""
|
|
||||||
if a is None:
|
|
||||||
return b
|
|
||||||
if b is None:
|
|
||||||
return a
|
|
||||||
return a if _to_epoch(a) >= _to_epoch(b) else b
|
|
||||||
|
|
||||||
|
|
||||||
def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
||||||
"""SQLite row → CodingMemory"""
|
"""SQLite row → CodingMemory"""
|
||||||
return CodingMemory(
|
return CodingMemory(
|
||||||
@@ -336,7 +308,6 @@ def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
|||||||
confidence=row["confidence"],
|
confidence=row["confidence"],
|
||||||
source=row["source"],
|
source=row["source"],
|
||||||
extraction_source=row["extraction_source"],
|
extraction_source=row["extraction_source"],
|
||||||
memory_at=_from_epoch(row["memory_at"]),
|
|
||||||
created_at=_parse_dt(row["created_at"]),
|
created_at=_parse_dt(row["created_at"]),
|
||||||
updated_at=_parse_dt(row["updated_at"]),
|
updated_at=_parse_dt(row["updated_at"]),
|
||||||
)
|
)
|
||||||
@@ -363,7 +334,6 @@ def _row_to_fact(row: sqlite3.Row) -> WorkingFact:
|
|||||||
# 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下
|
# 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下
|
||||||
fact_chain_summary=(row["fact_chain_summary"] or ""
|
fact_chain_summary=(row["fact_chain_summary"] or ""
|
||||||
if "fact_chain_summary" in row.keys() else ""),
|
if "fact_chain_summary" in row.keys() else ""),
|
||||||
memory_at=_from_epoch(row["memory_at"]),
|
|
||||||
created_at=_parse_dt(row["created_at"]),
|
created_at=_parse_dt(row["created_at"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -627,8 +597,6 @@ class CodingMemoryStore:
|
|||||||
memory.updated_at = datetime.utcnow()
|
memory.updated_at = datetime.utcnow()
|
||||||
if memory.created_at is None:
|
if memory.created_at is None:
|
||||||
memory.created_at = prev.created_at
|
memory.created_at = prev.created_at
|
||||||
# 合并后的经验讲的是两段对话,对话时间取较晚的那段
|
|
||||||
memory.memory_at = _later(prev.memory_at, memory.memory_at)
|
|
||||||
await _run_in_pool(self._upsert_sql_sync, memory)
|
await _run_in_pool(self._upsert_sql_sync, memory)
|
||||||
# 删旧 VDB
|
# 删旧 VDB
|
||||||
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
|
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
|
||||||
@@ -1231,8 +1199,8 @@ class CodingMemoryStore:
|
|||||||
experience_id, user_id, agent_id, task, search_keys, solution,
|
experience_id, user_id, agent_id, task, search_keys, solution,
|
||||||
boundary_envs, workspace_id, branch, session_id,
|
boundary_envs, workspace_id, branch, session_id,
|
||||||
files, source_fact_ids, confidence, source, extraction_source,
|
files, source_fact_ids, confidence, source, extraction_source,
|
||||||
memory_at, created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(experience_id) DO UPDATE SET
|
ON CONFLICT(experience_id) DO UPDATE SET
|
||||||
user_id = excluded.user_id,
|
user_id = excluded.user_id,
|
||||||
agent_id = excluded.agent_id,
|
agent_id = excluded.agent_id,
|
||||||
@@ -1248,7 +1216,6 @@ class CodingMemoryStore:
|
|||||||
confidence = excluded.confidence,
|
confidence = excluded.confidence,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
extraction_source = excluded.extraction_source,
|
extraction_source = excluded.extraction_source,
|
||||||
memory_at = excluded.memory_at,
|
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
@@ -1259,7 +1226,6 @@ class CodingMemoryStore:
|
|||||||
json.dumps(memory.files, ensure_ascii=False),
|
json.dumps(memory.files, ensure_ascii=False),
|
||||||
json.dumps(memory.source_fact_ids, ensure_ascii=False),
|
json.dumps(memory.source_fact_ids, ensure_ascii=False),
|
||||||
memory.confidence, memory.source, memory.extraction_source,
|
memory.confidence, memory.source, memory.extraction_source,
|
||||||
_to_epoch(memory.memory_at),
|
|
||||||
(memory.created_at or datetime.utcnow()).isoformat(),
|
(memory.created_at or datetime.utcnow()).isoformat(),
|
||||||
(memory.updated_at or datetime.utcnow()).isoformat(),
|
(memory.updated_at or datetime.utcnow()).isoformat(),
|
||||||
),
|
),
|
||||||
@@ -1400,8 +1366,8 @@ class CodingMemoryStore:
|
|||||||
fact_id, user_id, agent_id, session_id, workspace_id, branch,
|
fact_id, user_id, agent_id, session_id, workspace_id, branch,
|
||||||
source, statement, files, subject, details,
|
source, statement, files, subject, details,
|
||||||
fact_link, derived_experience_ids,
|
fact_link, derived_experience_ids,
|
||||||
status, fact_chain_summary, memory_at, created_at
|
status, fact_chain_summary, created_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(fact_id) DO UPDATE SET
|
ON CONFLICT(fact_id) DO UPDATE SET
|
||||||
user_id = excluded.user_id,
|
user_id = excluded.user_id,
|
||||||
agent_id = excluded.agent_id,
|
agent_id = excluded.agent_id,
|
||||||
@@ -1416,8 +1382,7 @@ class CodingMemoryStore:
|
|||||||
fact_link = excluded.fact_link,
|
fact_link = excluded.fact_link,
|
||||||
derived_experience_ids = excluded.derived_experience_ids,
|
derived_experience_ids = excluded.derived_experience_ids,
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
fact_chain_summary = excluded.fact_chain_summary,
|
fact_chain_summary = excluded.fact_chain_summary
|
||||||
memory_at = excluded.memory_at
|
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
fact.fact_id, fact.user_id, fact.agent_id,
|
fact.fact_id, fact.user_id, fact.agent_id,
|
||||||
@@ -1430,7 +1395,6 @@ class CodingMemoryStore:
|
|||||||
json.dumps(fact.derived_experience_ids, ensure_ascii=False),
|
json.dumps(fact.derived_experience_ids, ensure_ascii=False),
|
||||||
fact.status,
|
fact.status,
|
||||||
fact.fact_chain_summary or "",
|
fact.fact_chain_summary or "",
|
||||||
_to_epoch(fact.memory_at),
|
|
||||||
(fact.created_at or datetime.utcnow()).isoformat(),
|
(fact.created_at or datetime.utcnow()).isoformat(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,11 +25,6 @@ FactStatus = Literal["active", "superseded"]
|
|||||||
MAX_FACT_CHAIN_ENTRIES = 8
|
MAX_FACT_CHAIN_ENTRIES = 8
|
||||||
|
|
||||||
|
|
||||||
def _epoch(value: Optional[datetime]) -> Optional[int]:
|
|
||||||
"""memory_at 对外是秒级 epoch,与 chat 一致。"""
|
|
||||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# 草稿(LLM extract 阶段输出)
|
# 草稿(LLM extract 阶段输出)
|
||||||
# ================================================================
|
# ================================================================
|
||||||
@@ -58,8 +53,6 @@ class CodingMemoryDraft:
|
|||||||
files: List[str] = field(default_factory=list)
|
files: List[str] = field(default_factory=list)
|
||||||
source: str = "default"
|
source: str = "default"
|
||||||
extraction_source: str = "auto_extract"
|
extraction_source: str = "auto_extract"
|
||||||
# 对话时间(SDK 注入,同 chat 的 memory_at)
|
|
||||||
memory_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
from ..utils.source_filter import normalize_source
|
from ..utils.source_filter import normalize_source
|
||||||
@@ -80,7 +73,6 @@ class CodingMemoryDraft:
|
|||||||
"files": list(self.files),
|
"files": list(self.files),
|
||||||
"source": self.source,
|
"source": self.source,
|
||||||
"extraction_source": self.extraction_source,
|
"extraction_source": self.extraction_source,
|
||||||
"memory_at": _epoch(self.memory_at),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -119,9 +111,6 @@ class CodingMemory:
|
|||||||
extraction_source: str = "auto_extract"
|
extraction_source: str = "auto_extract"
|
||||||
|
|
||||||
# 时间
|
# 时间
|
||||||
# memory_at 是对话时间(同 chat),created_at / updated_at 是写库时间。
|
|
||||||
# UPDATE 合并时取两者中较晚的那个对话时间,见 store.update。
|
|
||||||
memory_at: Optional[datetime] = None
|
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
@@ -146,7 +135,6 @@ class CodingMemory:
|
|||||||
"confidence": self.confidence,
|
"confidence": self.confidence,
|
||||||
"source": self.source,
|
"source": self.source,
|
||||||
"extraction_source": self.extraction_source,
|
"extraction_source": self.extraction_source,
|
||||||
"memory_at": _epoch(self.memory_at),
|
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
@@ -261,7 +249,6 @@ class WorkingFact:
|
|||||||
fact_chain_summary: str = ""
|
fact_chain_summary: str = ""
|
||||||
|
|
||||||
# 时间
|
# 时间
|
||||||
memory_at: Optional[datetime] = None # 对话时间(同 chat),没传时是收到请求的时间
|
|
||||||
created_at: Optional[datetime] = None # 产生时间,新的优先
|
created_at: Optional[datetime] = None # 产生时间,新的优先
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
@@ -285,7 +272,6 @@ class WorkingFact:
|
|||||||
"derived_experience_ids": list(self.derived_experience_ids),
|
"derived_experience_ids": list(self.derived_experience_ids),
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"fact_chain_summary": self.fact_chain_summary,
|
"fact_chain_summary": self.fact_chain_summary,
|
||||||
"memory_at": _epoch(self.memory_at),
|
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ CodingWriter - 写入端协调器
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from ..agent.llm_provider import LLMProvider
|
from ..agent.llm_provider import LLMProvider
|
||||||
@@ -80,11 +79,9 @@ class CodingWriter:
|
|||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
request_id: Optional[str] = None,
|
request_id: Optional[str] = None,
|
||||||
source: str = "default",
|
source: str = "default",
|
||||||
memory_at: Optional[datetime] = None,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和
|
working/coding 段写入端到端。返回响应 dict(兼容 client.add 返回结构):
|
||||||
事实上;不传则用收到请求的时间(与 chat 链路一致)。返回响应 dict(兼容 client.add 返回结构):
|
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"scene": "productivity",
|
"scene": "productivity",
|
||||||
@@ -104,8 +101,6 @@ class CodingWriter:
|
|||||||
"""
|
"""
|
||||||
request_id = request_id or str(uuid.uuid4())
|
request_id = request_id or str(uuid.uuid4())
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
if memory_at is None:
|
|
||||||
memory_at = datetime.now()
|
|
||||||
|
|
||||||
if not messages:
|
if not messages:
|
||||||
return self._empty_response(request_id, t0)
|
return self._empty_response(request_id, t0)
|
||||||
@@ -177,7 +172,6 @@ class CodingWriter:
|
|||||||
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
|
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
|
||||||
for draft in drafts:
|
for draft in drafts:
|
||||||
draft.source = source
|
draft.source = source
|
||||||
draft.memory_at = memory_at
|
|
||||||
|
|
||||||
# 3. 经验层 reconcile + 持久化
|
# 3. 经验层 reconcile + 持久化
|
||||||
ops: List[ReconcileOp] = (
|
ops: List[ReconcileOp] = (
|
||||||
@@ -203,7 +197,6 @@ class CodingWriter:
|
|||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
branch=branch,
|
branch=branch,
|
||||||
source=source,
|
source=source,
|
||||||
memory_at=memory_at,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
experience_ids = [op.target_experience_id for op in ops
|
experience_ids = [op.target_experience_id for op in ops
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
MemoryGraphNode,
|
MemoryGraphNode,
|
||||||
MemoryGraphResponse,
|
MemoryGraphResponse,
|
||||||
MemoryProcessingSummary,
|
MemoryProcessingSummary,
|
||||||
SourceImportReceipt as Receipt,
|
|
||||||
SourceResponse,
|
SourceResponse,
|
||||||
} from "@one-memory/contracts";
|
} from "@one-memory/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -40,12 +39,6 @@ import { afterEach, expect, it, vi } from "vitest";
|
|||||||
import { MemoryMap } from "./MemoryMap";
|
import { MemoryMap } from "./MemoryMap";
|
||||||
import type { MemoryArrival } from "./memory-arrival-state";
|
import type { MemoryArrival } from "./memory-arrival-state";
|
||||||
|
|
||||||
const { sourceReceipt } = vi.hoisted(() => ({
|
|
||||||
sourceReceipt: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("./api", () => ({
|
|
||||||
getSourceImportReceipt: sourceReceipt,
|
|
||||||
}));
|
|
||||||
vi.mock("./MemoryGraph3D", () => ({
|
vi.mock("./MemoryGraph3D", () => ({
|
||||||
MemoryGraph3D: ({
|
MemoryGraph3D: ({
|
||||||
graph,
|
graph,
|
||||||
@@ -982,35 +975,6 @@ const importedSources = [
|
|||||||
imported_conversations: 1,
|
imported_conversations: 1,
|
||||||
},
|
},
|
||||||
] as SourceResponse[];
|
] as SourceResponse[];
|
||||||
const importedReceipt: Receipt = {
|
|
||||||
source_id: "codex",
|
|
||||||
measured_at: "2026-09-14T00:00:00Z",
|
|
||||||
messages: 5,
|
|
||||||
conversations: 2,
|
|
||||||
person_messages: 2,
|
|
||||||
agent_messages: 2,
|
|
||||||
tool_messages: 0,
|
|
||||||
unknown_author_messages: 1,
|
|
||||||
unknown_time_messages: 1,
|
|
||||||
examples: [
|
|
||||||
{
|
|
||||||
evidence_id: "raw-codex",
|
|
||||||
role: "user",
|
|
||||||
written_by: "person",
|
|
||||||
created_at: null,
|
|
||||||
excerpt: "合成来源原文,不是治理记忆。",
|
|
||||||
truncated: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
async function openSourceReceipt() {
|
|
||||||
await userEvent.click(
|
|
||||||
screen.getByRole("button", { name: "图中选择 Codex · 2 段" }),
|
|
||||||
);
|
|
||||||
await userEvent.click(screen.getByText("查看已读内容"));
|
|
||||||
await waitFor(() => expect(sourceReceipt).toHaveBeenCalledWith("codex"));
|
|
||||||
}
|
|
||||||
|
|
||||||
it("REV09: selecting an imported source retains the canvas and only its authorized node", async () => {
|
it("REV09: selecting an imported source retains the canvas and only its authorized node", async () => {
|
||||||
render(<Host items={[]} sources={importedSources} />);
|
render(<Host items={[]} sources={importedSources} />);
|
||||||
const canvas = screen.getByTestId("canvas");
|
const canvas = screen.getByTestId("canvas");
|
||||||
@@ -1025,7 +989,8 @@ it("REV09: selecting an imported source retains the canvas and only its authoriz
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("list", { name: "地图记忆列表" }).children,
|
screen.getByRole("list", { name: "地图记忆列表" }).children,
|
||||||
).toHaveLength(0);
|
).toHaveLength(0);
|
||||||
expect(sourceReceipt).not.toHaveBeenCalled();
|
expect(screen.getByRole("heading", { name: "记忆详情" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
|
||||||
await userEvent.click(screen.getByRole("button", { name: /全部记忆/ }));
|
await userEvent.click(screen.getByRole("button", { name: /全部记忆/ }));
|
||||||
expect(within(canvas).getAllByRole("button")).toHaveLength(2);
|
expect(within(canvas).getAllByRole("button")).toHaveLength(2);
|
||||||
expect(canvas.dataset.selected ?? "").toBe("");
|
expect(canvas.dataset.selected ?? "").toBe("");
|
||||||
@@ -1047,83 +1012,247 @@ it("REV09: fallback search matches source names and IDs without bypassing the se
|
|||||||
);
|
);
|
||||||
await userEvent.type(search, "cOdEx");
|
await userEvent.type(search, "cOdEx");
|
||||||
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
||||||
expect(screen.getByText("查看已读内容")).toBeTruthy();
|
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
|
||||||
await userEvent.clear(search);
|
await userEvent.clear(search);
|
||||||
await userEvent.type(search, "Cursor");
|
await userEvent.type(search, "Cursor");
|
||||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||||
expect(screen.queryByText("查看已读内容")).toBeNull();
|
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
|
||||||
await userEvent.clear(search);
|
await userEvent.clear(search);
|
||||||
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
|
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
|
||||||
expect(screen.queryByText("查看已读内容")).toBeNull();
|
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("REV09: source selection uses the actual permission-checked receipt API", async () => {
|
it("selecting a source node waits for its settled page, then opens the first memory", async () => {
|
||||||
sourceReceipt.mockResolvedValue(importedReceipt);
|
const onQuery = vi.fn();
|
||||||
render(<Host items={[]} sources={importedSources} />);
|
const onSource = vi.fn();
|
||||||
await openSourceReceipt();
|
const resetPage = vi.fn();
|
||||||
expect(
|
const oldPage = {
|
||||||
await screen.findByText(importedReceipt.examples[0].excerpt),
|
items: memories,
|
||||||
).toBeTruthy();
|
total: 2,
|
||||||
expect(screen.getByText("用户 · 时间未知")).toBeTruthy();
|
offset: 0,
|
||||||
expect(screen.getByText("原始消息").nextElementSibling?.textContent).toBe(
|
limit: 20,
|
||||||
"5",
|
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||||
|
};
|
||||||
|
const view = render(
|
||||||
|
<Host
|
||||||
|
simplified
|
||||||
|
sources={importedSources}
|
||||||
|
libraryView={{
|
||||||
|
...pagedView(oldPage),
|
||||||
|
query: "旧查询",
|
||||||
|
settledQuery: "旧查询",
|
||||||
|
onQuery,
|
||||||
|
onSource,
|
||||||
|
resetPage,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
// No raw conversation is read by id on the SDK data plane.
|
await act(async () => {});
|
||||||
expect(screen.queryByRole("button", { name: "查看完整对话" })).toBeNull();
|
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||||
|
expect(onQuery).toHaveBeenCalledWith("");
|
||||||
|
expect(onSource).toHaveBeenCalledWith("codex");
|
||||||
|
expect(resetPage).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<Host
|
||||||
|
simplified
|
||||||
|
sources={importedSources}
|
||||||
|
libraryView={{
|
||||||
|
...pagedView(oldPage),
|
||||||
|
source: "codex",
|
||||||
|
settledSource: "",
|
||||||
|
loading: true,
|
||||||
|
onQuery,
|
||||||
|
onSource,
|
||||||
|
resetPage,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getAllByRole("status")
|
||||||
|
.some((node) => node.textContent === "正在读取记忆详情…"),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const settledItems = [memories[1], memories[0]];
|
||||||
|
view.rerender(
|
||||||
|
<Host
|
||||||
|
simplified
|
||||||
|
items={settledItems}
|
||||||
|
sources={importedSources}
|
||||||
|
libraryView={{
|
||||||
|
...pagedView({
|
||||||
|
...oldPage,
|
||||||
|
items: settledItems,
|
||||||
|
source_ids_by_memory: { m1: ["codex"], m0: ["codex"] },
|
||||||
|
}),
|
||||||
|
source: "codex",
|
||||||
|
settledSource: "codex",
|
||||||
|
settledQuery: "",
|
||||||
|
onQuery,
|
||||||
|
onSource,
|
||||||
|
resetPage,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||||
|
expect(screen.queryByText("查看已读内容")).toBeNull();
|
||||||
|
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["revoked", "removed", "revoked-hidden"])(
|
it("does not replace a newer memory choice when a source page settles", async () => {
|
||||||
"REV09: %s source drops fallback, preview and a late receipt without reopening on reauthorization",
|
const page = {
|
||||||
async (state) => {
|
items: memories,
|
||||||
let finish: (value: Receipt) => void = () => {};
|
total: 2,
|
||||||
sourceReceipt.mockImplementation(
|
offset: 0,
|
||||||
() =>
|
limit: 20,
|
||||||
new Promise<Receipt>((resolve) => {
|
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||||
finish = resolve;
|
};
|
||||||
}),
|
const view = render(
|
||||||
);
|
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
|
||||||
const view = render(<Host items={[]} sources={[importedSources[0]]} />);
|
);
|
||||||
await openSourceReceipt();
|
await act(async () => {});
|
||||||
view.rerender(
|
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||||
<Host
|
await userEvent.click(
|
||||||
items={[]}
|
screen.getByRole("button", { name: "图中选择 第二条" }),
|
||||||
hidden={state === "revoked-hidden"}
|
);
|
||||||
sources={
|
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||||
state === "removed"
|
|
||||||
? []
|
|
||||||
: [{ ...importedSources[0], authorized: false }]
|
|
||||||
}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
|
||||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
|
||||||
await act(async () => finish(importedReceipt));
|
|
||||||
view.rerender(<Host items={[]} sources={[importedSources[0]]} />);
|
|
||||||
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
|
|
||||||
expect(screen.queryByText(importedReceipt.examples[0].excerpt)).toBeNull();
|
|
||||||
expect(screen.queryByText("查看已读内容")).toBeNull();
|
|
||||||
expect(sourceReceipt).toHaveBeenCalledTimes(1);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("REV09: compact preview close keeps source selection for reopening", async () => {
|
view.rerender(
|
||||||
|
<Host
|
||||||
|
simplified
|
||||||
|
sources={importedSources}
|
||||||
|
libraryView={{
|
||||||
|
...pagedView(page),
|
||||||
|
source: "codex",
|
||||||
|
settledSource: "codex",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing the compact source inspector cancels its pending auto-open", async () => {
|
||||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
matches: query.includes("max-width"),
|
matches: query.includes("max-width"),
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
removeEventListener() {},
|
removeEventListener() {},
|
||||||
}));
|
}));
|
||||||
sourceReceipt.mockResolvedValue(importedReceipt);
|
const page = {
|
||||||
render(<Host items={[]} sources={importedSources} />);
|
items: memories,
|
||||||
await openSourceReceipt();
|
total: 2,
|
||||||
await screen.findByText(importedReceipt.examples[0].excerpt);
|
offset: 0,
|
||||||
|
limit: 20,
|
||||||
|
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||||
|
};
|
||||||
|
const view = render(
|
||||||
|
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
|
||||||
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "关闭记忆详情" }));
|
await userEvent.click(screen.getByRole("button", { name: "关闭记忆详情" }));
|
||||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
|
||||||
|
view.rerender(
|
||||||
|
<Host
|
||||||
|
simplified
|
||||||
|
sources={importedSources}
|
||||||
|
libraryView={{
|
||||||
|
...pagedView(page),
|
||||||
|
source: "codex",
|
||||||
|
settledSource: "codex",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("dialog", { name: "记忆与原文" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the source selection and offers retry when its page fails", async () => {
|
||||||
|
const onRetry = vi.fn();
|
||||||
|
const page = {
|
||||||
|
items: memories,
|
||||||
|
total: 2,
|
||||||
|
offset: 0,
|
||||||
|
limit: 20,
|
||||||
|
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||||
|
};
|
||||||
|
const view = render(
|
||||||
|
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
|
||||||
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||||
|
view.rerender(
|
||||||
|
<Host
|
||||||
|
simplified
|
||||||
|
sources={importedSources}
|
||||||
|
libraryView={{
|
||||||
|
...pagedView(page),
|
||||||
|
source: "codex",
|
||||||
|
settledSource: "codex",
|
||||||
|
failed: true,
|
||||||
|
onRetry,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getAllByRole("alert")
|
||||||
|
.some((node) => node.textContent === "记忆暂时无法读取"),
|
||||||
|
).toBe(true);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "重试" }));
|
||||||
|
expect(onRetry).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a pending source selection when that source is revoked", async () => {
|
||||||
|
const page = {
|
||||||
|
items: memories,
|
||||||
|
total: 2,
|
||||||
|
offset: 0,
|
||||||
|
limit: 20,
|
||||||
|
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||||
|
};
|
||||||
|
const view = render(
|
||||||
|
<Host
|
||||||
|
simplified
|
||||||
|
sources={[importedSources[0]]}
|
||||||
|
libraryView={pagedView(page)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||||
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
|
||||||
await userEvent.click(
|
|
||||||
screen.getByRole("button", { name: "查看选中来源原文" }),
|
view.rerender(
|
||||||
|
<Host
|
||||||
|
simplified
|
||||||
|
sources={[{ ...importedSources[0], authorized: false }]}
|
||||||
|
libraryView={{
|
||||||
|
...pagedView(page),
|
||||||
|
source: "codex",
|
||||||
|
settledSource: "codex",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
|
||||||
|
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
|
||||||
|
"source:codex",
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<Host
|
||||||
|
simplified
|
||||||
|
sources={[importedSources[0]]}
|
||||||
|
libraryView={{
|
||||||
|
...pagedView(page),
|
||||||
|
source: "codex",
|
||||||
|
settledSource: "codex",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
|
||||||
|
"source:codex",
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("dialog", { name: "记忆与原文" })).toBeTruthy();
|
|
||||||
expect(screen.getByText("查看已读内容")).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("REV09: first governed memories replace fallback without remounting the graph or breaking memory IDs", async () => {
|
it("REV09: first governed memories replace fallback without remounting the graph or breaking memory IDs", async () => {
|
||||||
@@ -1162,5 +1291,4 @@ it.each([
|
|||||||
render(<Host items={[]} sources={sources} />);
|
render(<Host items={[]} sources={sources} />);
|
||||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||||
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
expect(document.querySelector(".source-import-receipt")).toBeNull();
|
||||||
expect(sourceReceipt).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
import { OptionMenu } from "./OptionMenu";
|
import { OptionMenu } from "./OptionMenu";
|
||||||
import { R6Button } from "./R6Controls";
|
import { R6Button } from "./R6Controls";
|
||||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||||
import { SourceImportReceipt } from "./SourceImportReceipt";
|
|
||||||
import { sourceNameOr } from "./source-label";
|
import { sourceNameOr } from "./source-label";
|
||||||
import { useMediaPreference } from "./use-media-preference";
|
import { useMediaPreference } from "./use-media-preference";
|
||||||
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
|
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
|
||||||
@@ -97,6 +96,8 @@ type Props = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
|
settledSource?: string;
|
||||||
|
settledQuery?: string;
|
||||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||||
unattributedSource?: string;
|
unattributedSource?: string;
|
||||||
onQuery(value: string): void;
|
onQuery(value: string): void;
|
||||||
@@ -213,6 +214,9 @@ export function MemoryMap(props: Props) {
|
|||||||
}, [submittedQuery]);
|
}, [submittedQuery]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
|
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
|
||||||
|
const [pendingSourceSelection, setPendingSourceSelection] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
const [question, setQuestion] = useState("");
|
const [question, setQuestion] = useState("");
|
||||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||||
const [filterNotice, setFilterNotice] = useState("");
|
const [filterNotice, setFilterNotice] = useState("");
|
||||||
@@ -363,6 +367,7 @@ export function MemoryMap(props: Props) {
|
|||||||
// Source revocation also clears a suspended preview, without closing shared memory detail.
|
// Source revocation also clears a suspended preview, without closing shared memory detail.
|
||||||
if (groupNode && !visibleGroupNode) {
|
if (groupNode && !visibleGroupNode) {
|
||||||
setGroupNode(null);
|
setGroupNode(null);
|
||||||
|
setPendingSourceSelection(null);
|
||||||
setInspectorOpen(false);
|
setInspectorOpen(false);
|
||||||
}
|
}
|
||||||
}, [groupNode, visibleGroupNode]);
|
}, [groupNode, visibleGroupNode]);
|
||||||
@@ -389,6 +394,7 @@ export function MemoryMap(props: Props) {
|
|||||||
const pick = useCallback(
|
const pick = useCallback(
|
||||||
(memoryId: string, scroll = true) => {
|
(memoryId: string, scroll = true) => {
|
||||||
if (!active.some((memory) => memory.memory_id === memoryId)) return;
|
if (!active.some((memory) => memory.memory_id === memoryId)) return;
|
||||||
|
setPendingSourceSelection(null);
|
||||||
scrollSelection.current = scroll;
|
scrollSelection.current = scroll;
|
||||||
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
|
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
|
||||||
setGroupKey("");
|
setGroupKey("");
|
||||||
@@ -402,6 +408,32 @@ export function MemoryMap(props: Props) {
|
|||||||
},
|
},
|
||||||
[active, filtered, onOpenMemory],
|
[active, filtered, onOpenMemory],
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!library ||
|
||||||
|
!pendingSourceSelection ||
|
||||||
|
overlaysSuspended ||
|
||||||
|
library.loading ||
|
||||||
|
library.failed ||
|
||||||
|
!selectedSource ||
|
||||||
|
selectedSource.source_id !== pendingSourceSelection ||
|
||||||
|
library.source !== pendingSourceSelection ||
|
||||||
|
(library.settledSource ?? library.source) !== pendingSourceSelection ||
|
||||||
|
(library.settledQuery ?? library.query).trim() ||
|
||||||
|
library.page?.offset !== 0
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setPendingSourceSelection(null);
|
||||||
|
const first = active[0];
|
||||||
|
if (first) pick(first.memory_id, false);
|
||||||
|
}, [
|
||||||
|
active,
|
||||||
|
library,
|
||||||
|
overlaysSuspended,
|
||||||
|
pendingSourceSelection,
|
||||||
|
pick,
|
||||||
|
selectedSource,
|
||||||
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
|
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
|
||||||
if (library?.loading && filtered.length === 0) return;
|
if (library?.loading && filtered.length === 0) return;
|
||||||
@@ -452,6 +484,7 @@ export function MemoryMap(props: Props) {
|
|||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setGroupNode(null);
|
setGroupNode(null);
|
||||||
|
setPendingSourceSelection(null);
|
||||||
setInspectorOpen(false);
|
setInspectorOpen(false);
|
||||||
onCloseDetail();
|
onCloseDetail();
|
||||||
}, [onCloseDetail]);
|
}, [onCloseDetail]);
|
||||||
@@ -483,6 +516,7 @@ export function MemoryMap(props: Props) {
|
|||||||
clear();
|
clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setPendingSourceSelection(null);
|
||||||
setInspectorOpen(false);
|
setInspectorOpen(false);
|
||||||
}
|
}
|
||||||
function chooseGroup(key: string) {
|
function chooseGroup(key: string) {
|
||||||
@@ -509,8 +543,17 @@ export function MemoryMap(props: Props) {
|
|||||||
setAxis("source");
|
setAxis("source");
|
||||||
setGroupKey(`source:${node.source_id}`);
|
setGroupKey(`source:${node.source_id}`);
|
||||||
if (library) {
|
if (library) {
|
||||||
|
setPendingSourceSelection(node.source_id);
|
||||||
|
library.onQuery("");
|
||||||
library.onSource(node.source_id);
|
library.onSource(node.source_id);
|
||||||
library.resetPage();
|
library.resetPage();
|
||||||
|
} else {
|
||||||
|
const first = sourceGroups
|
||||||
|
.find((entry) => entry.key === `source:${node.source_id}`)
|
||||||
|
?.memoryIds.find((memoryId) =>
|
||||||
|
active.some((memory) => memory.memory_id === memoryId),
|
||||||
|
);
|
||||||
|
if (first) pick(first, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (node.node_type === "topic") {
|
if (node.node_type === "topic") {
|
||||||
@@ -987,11 +1030,13 @@ export function MemoryMap(props: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="quiet map-tool"
|
className="quiet map-tool"
|
||||||
aria-label={text(
|
aria-label={text("记忆详情")}
|
||||||
selectedSource ? "查看选中来源原文" : "查看选中记忆原文",
|
|
||||||
)}
|
|
||||||
title={text("查看原文")}
|
title={text("查看原文")}
|
||||||
onClick={() => setInspectorOpen(true)}
|
onClick={() => {
|
||||||
|
setInspectorOpen(true);
|
||||||
|
if (selectedSource && library)
|
||||||
|
setPendingSourceSelection(selectedSource.source_id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FileText size={17} />
|
<FileText size={17} />
|
||||||
</button>
|
</button>
|
||||||
@@ -1126,7 +1171,7 @@ export function MemoryMap(props: Props) {
|
|||||||
{inspectorDetail
|
{inspectorDetail
|
||||||
? text("记忆详情")
|
? text("记忆详情")
|
||||||
: selectedSource
|
: selectedSource
|
||||||
? text("来源原文")
|
? text("记忆详情")
|
||||||
: visibleGroupNode
|
: visibleGroupNode
|
||||||
? text("关联节点")
|
? text("关联节点")
|
||||||
: text("记忆详情")}
|
: text("记忆详情")}
|
||||||
@@ -1145,13 +1190,33 @@ export function MemoryMap(props: Props) {
|
|||||||
</header>
|
</header>
|
||||||
<div className="map-inspector-body" ref={inspectorBody}>
|
<div className="map-inspector-body" ref={inspectorBody}>
|
||||||
{selectedSource ? (
|
{selectedSource ? (
|
||||||
inspectorOpen &&
|
<div className="map-inspector-empty map-inspector-loading">
|
||||||
!overlaysSuspended && (
|
{library?.failed &&
|
||||||
<SourceImportReceipt
|
library.source === selectedSource.source_id ? (
|
||||||
key={selectedSource.source_id}
|
<>
|
||||||
source={selectedSource}
|
<p role="alert">{text("记忆暂时无法读取")}</p>
|
||||||
/>
|
<button
|
||||||
)
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={library.onRetry}
|
||||||
|
>
|
||||||
|
{text("重试")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : pendingSourceSelection || library?.loading ? (
|
||||||
|
<>
|
||||||
|
<div className="map-detail-skeleton" aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</div>
|
||||||
|
<p role="status">{text("正在读取记忆详情…")}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>{text("还没有已生成的记忆")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : props.simplified && inspectorDetail ? (
|
) : props.simplified && inspectorDetail ? (
|
||||||
<div
|
<div
|
||||||
aria-busy={
|
aria-busy={
|
||||||
|
|||||||
@@ -1027,6 +1027,8 @@ export function MemorySpace({
|
|||||||
loading: sdkLibrary.loading,
|
loading: sdkLibrary.loading,
|
||||||
failed: sdkLibrary.failed,
|
failed: sdkLibrary.failed,
|
||||||
stale: false,
|
stale: false,
|
||||||
|
settledSource: sdkLibrary.settledSource,
|
||||||
|
settledQuery: sdkLibrary.settledQuery,
|
||||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||||
onQuery: setSdkLibraryQuery,
|
onQuery: setSdkLibraryQuery,
|
||||||
onSource: setSdkLibrarySource,
|
onSource: setSdkLibrarySource,
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hy-memory"
|
name = "hy-memory"
|
||||||
version = "2.0.7"
|
version = "2.0.6"
|
||||||
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
|
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""经验与事实记下对话时间 memory_at,并以秒级 epoch 对外返回(与 chat 一致)。
|
|
||||||
|
|
||||||
之前生产力链路丢了 add() 传进来的 memory_at,coding_memory_meta / working_fact
|
|
||||||
也没有这一列,MemHub 里所有经验都显示"时间未知"。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from hy_memory.client import HyMemoryClient
|
|
||||||
from hy_memory.coding.extractor import CodingMemoryExtractor
|
|
||||||
from hy_memory.coding.reconciler import CodingMemoryReconciler
|
|
||||||
from hy_memory.coding.store import CodingMemoryStore, _CREATE_TABLE_SQL, _CREATE_FACT_TABLE_SQL
|
|
||||||
from hy_memory.coding.types import CodingMemory, WorkingFact
|
|
||||||
from hy_memory.coding.writer import CodingWriter
|
|
||||||
|
|
||||||
from tests.coding._helpers import MockLLMProvider
|
|
||||||
from tests.coding.test_list_and_stats_by_scene import _client, _unwrapped
|
|
||||||
from tests.coding.test_write_scene_routing import _messages
|
|
||||||
from tests.coding.test_writer_e2e import _coding_messages, _extract_resp, _reconcile_resp
|
|
||||||
|
|
||||||
T1 = datetime(2026, 10, 1, 9, 30, tzinfo=timezone.utc)
|
|
||||||
T2 = T1 + timedelta(days=3)
|
|
||||||
|
|
||||||
|
|
||||||
def _epoch(value: datetime) -> int:
|
|
||||||
return int(value.timestamp())
|
|
||||||
|
|
||||||
|
|
||||||
def _writer(store, embed, llm):
|
|
||||||
return CodingWriter(
|
|
||||||
store=store,
|
|
||||||
extractor=CodingMemoryExtractor(llm),
|
|
||||||
reconciler=CodingMemoryReconciler(store, embed, llm),
|
|
||||||
llm_provider=llm, embed_service=embed,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
class TestStore:
|
|
||||||
async def test_round_trip_as_epoch(self, coding_store):
|
|
||||||
await coding_store.insert(CodingMemory(
|
|
||||||
experience_id="e1", user_id="u", task="T", solution="S", memory_at=T1,
|
|
||||||
))
|
|
||||||
await coding_store.insert_fact(WorkingFact(
|
|
||||||
fact_id="f1", user_id="u", statement="x", memory_at=T1,
|
|
||||||
))
|
|
||||||
exp = await coding_store.get_by_id("e1", user_id="u")
|
|
||||||
fact = await coding_store.get_fact("f1", user_id="u")
|
|
||||||
assert _epoch(exp.memory_at) == _epoch(T1)
|
|
||||||
assert exp.to_dict()["memory_at"] == _epoch(T1)
|
|
||||||
assert fact.to_dict()["memory_at"] == _epoch(T1)
|
|
||||||
|
|
||||||
async def test_missing_time_stays_unknown(self, coding_store):
|
|
||||||
await coding_store.insert(CodingMemory(
|
|
||||||
experience_id="e1", user_id="u", task="T", solution="S",
|
|
||||||
))
|
|
||||||
exp = await coding_store.get_by_id("e1", user_id="u")
|
|
||||||
assert exp.memory_at is None and exp.to_dict()["memory_at"] is None
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("old, new", [(T1, T2), (T2, T1), (T2, None)])
|
|
||||||
async def test_update_keeps_the_later_time(self, coding_store, old, new):
|
|
||||||
await coding_store.insert(CodingMemory(
|
|
||||||
experience_id="e1", user_id="u", task="T", solution="S",
|
|
||||||
session_id="s", memory_at=old,
|
|
||||||
))
|
|
||||||
await coding_store.update(CodingMemory(
|
|
||||||
experience_id="e1", user_id="u", task="T", solution="S2",
|
|
||||||
session_id="s", memory_at=new,
|
|
||||||
))
|
|
||||||
exp = await coding_store.get_by_id("e1", user_id="u")
|
|
||||||
assert exp.solution == "S2"
|
|
||||||
assert _epoch(exp.memory_at) == _epoch(T2)
|
|
||||||
|
|
||||||
async def test_edit_keeps_the_time(self, coding_store):
|
|
||||||
await coding_store.insert(CodingMemory(
|
|
||||||
experience_id="e1", user_id="u", task="T", solution="S", memory_at=T1,
|
|
||||||
))
|
|
||||||
await coding_store.edit_experience("e1", user_id="u", solution="S2")
|
|
||||||
exp = await coding_store.get_by_id("e1", user_id="u")
|
|
||||||
assert _epoch(exp.memory_at) == _epoch(T1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("variant", ["working", "coding"])
|
|
||||||
async def test_existing_database_gains_the_column_on_open(coding_config, mock_embed, variant):
|
|
||||||
store = CodingMemoryStore(coding_config, embed_service=mock_embed, variant=variant)
|
|
||||||
# 先按老 schema(没有 memory_at)建库并放一行
|
|
||||||
conn = sqlite3.connect(store._db_path)
|
|
||||||
conn.executescript(_CREATE_TABLE_SQL.replace(" memory_at INTEGER,\n", ""))
|
|
||||||
conn.executescript(_CREATE_FACT_TABLE_SQL.replace(" memory_at INTEGER,\n", ""))
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO coding_memory_meta (experience_id, user_id, task, solution, "
|
|
||||||
"created_at, updated_at) VALUES ('old', 'u', 'T', 'S', '2026-01-01T00:00:00', "
|
|
||||||
"'2026-01-01T00:00:00')"
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
await store.initialize()
|
|
||||||
try:
|
|
||||||
old = await store.get_by_id("old", user_id="u")
|
|
||||||
assert old is not None and old.memory_at is None
|
|
||||||
await store.insert_fact(WorkingFact(
|
|
||||||
fact_id="f1", user_id="u", statement="x", memory_at=T1,
|
|
||||||
))
|
|
||||||
assert _epoch((await store.get_fact("f1")).memory_at) == _epoch(T1)
|
|
||||||
finally:
|
|
||||||
await store.close()
|
|
||||||
# 再开一次不出错(ADD COLUMN 是幂等的)
|
|
||||||
again = CodingMemoryStore(coding_config, embed_service=mock_embed, variant=variant)
|
|
||||||
await again.initialize()
|
|
||||||
await again.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
class TestWriter:
|
|
||||||
def _llm(self):
|
|
||||||
return MockLLMProvider([
|
|
||||||
_extract_resp(
|
|
||||||
[{"task": "打包发布", "solution": "S", "confidence": 0.9}],
|
|
||||||
[{"statement": "发布脚本在 scripts/publish_package.sh"}],
|
|
||||||
),
|
|
||||||
_reconcile_resp([{"draft_idx": 0, "action": "ADD"}]),
|
|
||||||
])
|
|
||||||
|
|
||||||
async def test_conversation_time_reaches_both_layers(self, coding_store, mock_embed):
|
|
||||||
resp = await _writer(coding_store, mock_embed, self._llm()).write(
|
|
||||||
_coding_messages(), user_id="u", session_id="s", memory_at=T1,
|
|
||||||
)
|
|
||||||
exp = await coding_store.get_by_id(resp["experience_ids"][0], user_id="u")
|
|
||||||
fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u")
|
|
||||||
assert _epoch(exp.memory_at) == _epoch(T1)
|
|
||||||
assert _epoch(fact.memory_at) == _epoch(T1)
|
|
||||||
|
|
||||||
async def test_defaults_to_request_time_like_chat(self, coding_store, mock_embed):
|
|
||||||
before = int(time.time())
|
|
||||||
resp = await _writer(coding_store, mock_embed, self._llm()).write(
|
|
||||||
_coding_messages(), user_id="u", session_id="s",
|
|
||||||
)
|
|
||||||
exp = await coding_store.get_by_id(resp["experience_ids"][0], user_id="u")
|
|
||||||
fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u")
|
|
||||||
for stamp in (_epoch(exp.memory_at), _epoch(fact.memory_at)):
|
|
||||||
assert before <= stamp <= int(time.time()) + 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_passes_the_time_to_the_productivity_writer(monkeypatch):
|
|
||||||
seen = {}
|
|
||||||
|
|
||||||
async def _judge(*args, **kwargs):
|
|
||||||
return "working"
|
|
||||||
|
|
||||||
class _Writer:
|
|
||||||
async def write(self, *args, **kwargs):
|
|
||||||
seen.update(kwargs)
|
|
||||||
return {"success": True, "experience_ids": [], "fact_ids": []}
|
|
||||||
|
|
||||||
async def _get_writer(*, variant):
|
|
||||||
return _Writer()
|
|
||||||
|
|
||||||
monkeypatch.setattr("hy_memory.client.classify_messages_scene", _judge)
|
|
||||||
ns = SimpleNamespace(
|
|
||||||
_config=SimpleNamespace(coding=SimpleNamespace(enable=True)),
|
|
||||||
_coding_llm_provider=object(),
|
|
||||||
_cache=None,
|
|
||||||
_get_coding_writer=_get_writer,
|
|
||||||
_normalize_scene=HyMemoryClient._normalize_scene,
|
|
||||||
_scene_variant=HyMemoryClient._scene_variant,
|
|
||||||
_parse_input=HyMemoryClient._parse_input,
|
|
||||||
)
|
|
||||||
await HyMemoryClient._async_add_impl(
|
|
||||||
ns, _messages(), user_id="u1", agent_id="a1", memory_at=T1,
|
|
||||||
)
|
|
||||||
assert seen["memory_at"] == T1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_get_and_search_return_epoch_seconds(coding_config, mock_embed):
|
|
||||||
coding = CodingMemoryStore(coding_config, embed_service=mock_embed, variant="coding")
|
|
||||||
await coding.initialize()
|
|
||||||
try:
|
|
||||||
await coding.insert(CodingMemory(
|
|
||||||
experience_id="e1", user_id="u", task="打包发布", solution="S", memory_at=T1,
|
|
||||||
))
|
|
||||||
await coding.insert_fact(WorkingFact(
|
|
||||||
fact_id="f1", user_id="u", statement="打包发布", memory_at=T2,
|
|
||||||
derived_experience_ids=["e1"],
|
|
||||||
))
|
|
||||||
client = _client([], {"coding": coding})
|
|
||||||
list_memories = _unwrapped("async_list_memories")
|
|
||||||
|
|
||||||
page = await list_memories(client, user_id="u", scene="coding")
|
|
||||||
assert page["experiences"][0]["memory_at"] == _epoch(T1)
|
|
||||||
|
|
||||||
merged = await list_memories(client, user_id="u", scene="all")
|
|
||||||
assert merged["memories"][0]["memory_at"] == _epoch(T1)
|
|
||||||
|
|
||||||
exp = await coding.get_by_id("e1", user_id="u")
|
|
||||||
got = HyMemoryClient._format_productivity_get(
|
|
||||||
exp, kind="experience", variant="coding", request_id="r", elapsed_ms=0,
|
|
||||||
)
|
|
||||||
assert got["memory_at"] == _epoch(T1)
|
|
||||||
|
|
||||||
client._embed_service = mock_embed
|
|
||||||
client._coding_store = coding
|
|
||||||
|
|
||||||
async def _ready(variant="working"):
|
|
||||||
return None
|
|
||||||
|
|
||||||
client._get_coding_writer = _ready
|
|
||||||
client._cache = None
|
|
||||||
resp = await HyMemoryClient._search_coding(
|
|
||||||
client, target_query="打包发布", user_ids=["u"], workspace_id=None,
|
|
||||||
branch=None, limit=5, working_fact_min_score=0.0,
|
|
||||||
working_experience_min_score=0.0, request_id="r",
|
|
||||||
t0=time.perf_counter(), variant="coding",
|
|
||||||
)
|
|
||||||
assert resp["facts"][0]["memory_at"] == _epoch(T2)
|
|
||||||
assert resp["experiences"][0]["memory_at"] == _epoch(T1)
|
|
||||||
unified = HyMemoryClient._search_lane_items("coding", resp)
|
|
||||||
assert {item["memory_at"] for item in unified} == {_epoch(T1), _epoch(T2)}
|
|
||||||
finally:
|
|
||||||
await coding.close()
|
|
||||||
Reference in new issue
Block a user