Files
Hy-Memory/hy_memory/coding/writer.py
T
alvinfeiandClaude Opus 5.5 16da11f788 feat(sdk): prompts in 11 languages and an explicit language option
Move every LLM prompt that had a zh variant into one registry,
hy_memory/prompts/, with one module per language (en, zh-Hans, zh-Hant,
ja, es, de, pt, fr, ko, ru, id). Call sites look prompts up with
get_prompt(name, language), so a new language needs only a new module.
The en and zh-Hans strings are moved verbatim, a test pins their sha256,
and the old names (prompts_zh.*_ZH, extractor.EXTRACT_SYSTEM_PROMPT, ...)
still import.

Add an optional language: MemoryConfig.language (env MEMORY_LANGUAGE)
as the client default, plus a per-call language= on add / async_add,
add_extracted, finalize_session, evolve and sweep. It goes through
extract and basic profile, reconcile and search query, the coding
extractor and both coding reconcilers with the chain summary, the
episode and topic summaries, and System 2. Codes are validated, with
aliases (zh, zh-CN, zh-TW, zh-HK, pt-BR); an unknown code raises
ValueError. With an explicit language, memories are written in that
language. Without one, the zh/en auto-detection is unchanged. The
server accepts language on /add and /add_extracted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 21:29:59 +08:00

342 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
CodingWriter - 写入端协调器
把 judge / preproc / extractor / 两个 reconciler / store 串成一条端到端流程:
classify_messages_scene(messages, llm)
├── chat → caller 把 messages strip_tool_messages 后走 chat 链
└── working / coding
├── recent history: 最近 K 个 user-turn(只作上下文)
├── new messages: 本次全部 turn(只做单条限长)
├── extractor.extract(...) ← 一次 LLM 产出双层草稿
├── reconciler.reconcile(经验草稿) ← ADD/UPDATE/DELETE/SKIP
├── fact_reconciler.reconcile(事实草稿) ← INSERT/SUPERSEDE
└── _crosslink(...) ← 同批全互链回填
"""
import logging
import time
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from ..agent.llm_provider import LLMProvider
from ..core.embed_service import EmbedService
from ..pipelines.base import ChatMessage
from .extractor import CodingMemoryExtractor
from .fact_reconciler import WorkingFactReconciler
from .judge import classify_messages_is_coding
from .preproc import has_any_tool_message, prepare_extractor_messages
from .reconciler import CodingMemoryReconciler
from .store import CodingMemoryStore
from .types import FactReconcileOp, ReconcileOp, WorkingFact
logger = logging.getLogger(__name__)
class CodingWriter:
"""
Coding 写入端协调器。
生命周期:依赖外部传入的 store / extractor / reconciler;不负责 initialize/close。
"""
def __init__(
self,
store: CodingMemoryStore,
extractor: CodingMemoryExtractor,
reconciler: CodingMemoryReconciler,
llm_provider: LLMProvider,
embed_service: EmbedService,
fact_reconciler: Optional[WorkingFactReconciler] = None,
cache: Any = None,
variant: str = "working",
):
self.store = store
self.extractor = extractor
self.reconciler = reconciler
self.llm_provider = llm_provider
self.embed_service = embed_service
self.fact_reconciler = fact_reconciler or WorkingFactReconciler(
store, embed_service, llm_provider
)
self.variant = (
"coding"
if str(variant or "").strip().lower() == "coding"
else "working"
)
# cache 仅用于把 request_id 串给各阶段(各阶段自己打点);writer 不集中打点。
self.cache = cache
async def write(
self,
messages: List[ChatMessage],
*,
user_id: str,
agent_id: str = "default_agent",
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
session_id: Optional[str] = None,
request_id: Optional[str] = None,
source: str = "default",
memory_at: Optional[datetime] = None,
language: Optional[str] = None,
) -> Dict[str, Any]:
"""
working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和
事实上;不传则用收到请求的时间(与 chat 链路一致)。language 是显式 prompt
语言码,透传给抽取、经验 reconcile、事实 reconcile 与演变总结;None 时各步
沿用 zh/en 自动检测。返回响应 dict(兼容 client.add 返回结构):
{
"success": True,
"scene": "productivity",
"request_id": "...",
"elapsed_ms": ...,
"ops": [
{"action": "ADD", "draft_idx": 0, "target_experience_id": "...", "reason": ...},
...
],
"experience_ids": ["..."], # 所有 ADD/UPDATE 涉及的 experience_id
"fact_ops": [
{"action": "INSERT", "draft_idx": 0, "fact_id": "...", ...},
...
],
"fact_ids": ["..."], # 这趟真正落库的 fact_id
}
"""
request_id = request_id or str(uuid.uuid4())
t0 = time.perf_counter()
if memory_at is None:
memory_at = datetime.now()
if not messages:
return self._empty_response(request_id, t0)
tool_max_bytes = int(
getattr(self.store.config.coding, "tool_result_max_bytes", 2048) or 2048
)
# 1. 历史与新输入分离。
# - history_messages:之前 write 的最近 5 个 user-turn,仅作上下文
# - current_messages:本次 write 的全部 turn,只限制单条消息体积,不裁 turn
history_turns = int(
getattr(self.store.config.extractor, "history_turns", 5) or 5
)
try:
history_messages = await self.store.get_recent_trace_messages(
user_id=user_id,
agent_id=agent_id or "default_agent",
max_user_turns=history_turns,
source=source,
)
except Exception as e:
logger.warning(f"[coding-write] recent trace lookup failed, continue without history: {e}")
history_messages = []
current_messages = prepare_extractor_messages(
messages, tool_max_bytes=tool_max_bytes,
)
# 2. 抽取双层 drafts(一次 LLM 调用)
#
# 刻意不给 extractor 看用户已有的 task。抽取只该忠实读这一段发生了什么,
# 跟库里已有的什么关系是 reconcile 的事。把已有 task 摆到它面前,它会把
# 一个其实不同的任务往清单里某个措辞上靠,reconciler 随后判成 UPDATE 合并
# 掉,那个区别就没了——措辞对不上顶多多存一条,措辞被带偏是直接写错。
try:
drafts, fact_drafts = await self.extractor.extract(
messages,
history_messages=history_messages,
tool_result_max_bytes=tool_max_bytes,
user_id=user_id,
agent_id=agent_id,
workspace_id=workspace_id,
branch=branch,
session_id=session_id,
request_id=request_id,
language=language,
)
finally:
# 历史服务于下一次 write;即使本次没跨过价值门槛,也不能丢掉上下文。
try:
await self.store.append_trace(
current_messages,
request_id=request_id,
user_id=user_id,
agent_id=agent_id or "default_agent",
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
source=source,
)
except Exception as e:
logger.warning(f"[coding-write] append trace failed: {e}")
if not drafts and not fact_drafts:
logger.info(
f"[coding-write] no drafts produced (value bar); request_id={request_id}"
)
return self._empty_response(request_id, t0)
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
for draft in drafts:
draft.source = source
draft.memory_at = memory_at
# 3. 经验层 reconcile + 持久化
ops: List[ReconcileOp] = (
await self.reconciler.reconcile(
drafts,
request_id=request_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id or "",
language=language,
)
if drafts else []
)
# 4. 事实层 reconcile + 持久化(独立链路,不受经验层结果影响)
facts: List[WorkingFact] = []
fact_ops: List[FactReconcileOp] = []
if fact_drafts:
facts, fact_ops = await self.fact_reconciler.reconcile(
fact_drafts,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
workspace_id=workspace_id,
branch=branch,
source=source,
memory_at=memory_at,
language=language,
)
experience_ids = [op.target_experience_id for op in ops
if op.action in ("ADD", "UPDATE") and op.target_experience_id]
fact_ids = [f.fact_id for f in facts]
# 5. 同批全互链
await self._crosslink(experience_ids, facts, user_id=user_id)
add_cnt = sum(1 for op in ops if op.action == "ADD")
update_cnt = sum(1 for op in ops if op.action == "UPDATE")
delete_cnt = sum(1 for op in ops if op.action == "DELETE")
skip_cnt = sum(1 for op in ops if op.action == "SKIP")
ops_summary = {
"add": add_cnt,
"update": update_cnt,
"delete": delete_cnt,
"skip": skip_cnt,
"total": add_cnt + update_cnt + delete_cnt + skip_cnt,
}
insert_cnt = sum(1 for op in fact_ops if op.action == "INSERT")
supersede_cnt = sum(1 for op in fact_ops if op.action == "SUPERSEDE")
return {
"success": True,
"scene": "productivity",
"variant": self.variant,
"request_id": request_id,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"ops": [op.to_dict() for op in ops],
"ops_summary": ops_summary,
"experience_ids": experience_ids,
"fact_ops": [op.to_dict() for op in fact_ops],
"fact_ops_summary": {
"insert": insert_cnt,
"supersede": supersede_cnt,
"total": insert_cnt + supersede_cnt,
},
"fact_ids": fact_ids,
}
# ------------------------------------------------------------
# 互链
# ------------------------------------------------------------
async def _crosslink(
self,
experience_ids: List[str],
facts: List[WorkingFact],
*,
user_id: str,
) -> None:
"""把同一批落库的经验与事实两两连起来。
只连真入库的:经验取 ADD/UPDATE 的 id,事实取真正 INSERT 的;SKIP 和
DELETE 天然不在名单里。两边都取并集——UPDATE 的旧链要保留,事实那边可能
已经从 SUPERSEDE 过继来一批经验,不能覆盖掉。
"""
if not experience_ids and not facts:
return
fact_ids = [f.fact_id for f in facts]
# 正向:经验 ← 事实
if fact_ids:
for eid in experience_ids:
try:
exp = await self.store.get_by_id(eid, user_id=user_id)
if exp is None:
continue
merged = _union(exp.source_fact_ids, fact_ids)
if merged != list(exp.source_fact_ids):
await self.store.set_experience_source_facts(eid, merged)
except Exception as e:
logger.warning(f"[coding-write] crosslink experience {eid} failed: {e}")
# 反向:事实 ← 经验
if experience_ids:
for f in facts:
try:
merged = _union(f.derived_experience_ids, experience_ids)
if merged != list(f.derived_experience_ids):
await self.store.set_fact_derived_experiences(f.fact_id, merged)
f.derived_experience_ids = merged
except Exception as e:
logger.warning(
f"[coding-write] crosslink fact {f.fact_id} failed: {e}"
)
def _empty_response(self, request_id: str, t0: float) -> Dict[str, Any]:
return {
"success": True,
"scene": "productivity",
"variant": self.variant,
"request_id": request_id,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
"ops": [],
"ops_summary": {"add": 0, "update": 0, "delete": 0, "skip": 0, "total": 0},
"experience_ids": [],
"fact_ops": [],
"fact_ops_summary": {"insert": 0, "supersede": 0, "total": 0},
"fact_ids": [],
}
def _union(base: List[str], extra: List[str]) -> List[str]:
"""保序并集。"""
out = list(base)
seen = set(out)
for x in extra:
if x not in seen:
seen.add(x)
out.append(x)
return out
# ================================================================
# Top-level helper(client.async_add 用)
# ================================================================
async def is_coding_segment(
messages: List[ChatMessage],
llm_provider: LLMProvider,
) -> bool:
"""
判定整段 messages 是否 coding 场景。无 tool 消息直接 False(O(1) 短路)。
"""
if not has_any_tool_message(messages):
return False
return await classify_messages_is_coding(messages, llm_provider)