diff --git a/app/main.py b/app/main.py index a129fb6..2b73719 100644 --- a/app/main.py +++ b/app/main.py @@ -202,8 +202,16 @@ async def retrieve(request: Request, payload: dict[str, Any]): raise HTTPException(status_code=400, detail="'query' must be a non-empty string") try: service = request.app.state.service - result = await service.retrieve([query.strip()]) - return JSONResponse(content={"status": "success", "result": result}) + user_id = payload.get("user_id") + where = {"user_id": user_id} if user_id else None + skip_routing = payload.get("skip_routing", True) + service.retrieve_config.route_intention = not skip_routing + result = await service.retrieve([query.strip()], where=where) + # Sanitize datetime objects for JSON serialization + import json as _json + + sanitized = _json.loads(_json.dumps(result, default=str)) + return JSONResponse(content={"status": "success", "result": sanitized}) except Exception as exc: logger.exception("Retrieve request failed") raise HTTPException(status_code=500, detail="Internal server error") from exc diff --git a/app/services/memu.py b/app/services/memu.py index e8df706..f622afe 100644 --- a/app/services/memu.py +++ b/app/services/memu.py @@ -8,21 +8,22 @@ from config.settings import Settings +def _deep_merge(base: dict, override: dict) -> dict: + """Merge override into base dict recursively. Override wins on conflicts.""" + merged = {**base} + for key, val in override.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(val, dict): + merged[key] = _deep_merge(merged[key], val) + else: + merged[key] = val + return merged + + def create_memory_service( settings: Settings | None = None, memorize_config: dict[str, Any] | None = None, retrieve_config: dict[str, Any] | None = None, ) -> MemoryService: - """Create a configured MemoryService instance. - - Args: - settings: Application settings. Uses default if not provided. - memorize_config: Optional memorize workflow config override. - retrieve_config: Optional retrieve workflow config override. - - Returns: - Configured MemoryService instance. - """ if settings is None: settings = Settings() @@ -30,7 +31,8 @@ def create_memory_service( kwargs = {**memu_config} if memorize_config: - kwargs["memorize_config"] = memorize_config + base = kwargs.get("memorize_config", {}) + kwargs["memorize_config"] = _deep_merge(base, memorize_config) if retrieve_config: kwargs["retrieve_config"] = retrieve_config diff --git a/config/memu.py b/config/memu.py index fdf76da..e7602ba 100644 --- a/config/memu.py +++ b/config/memu.py @@ -1,21 +1,23 @@ """MemU configuration for memory service.""" +import logging +from pathlib import Path from typing import Any +import yaml from pydantic import BaseModel from config.settings import Settings +logger = logging.getLogger(__name__) -class MemUUser(BaseModel): - """User model for memu-py.""" +class MemUUser(BaseModel): user_id: str agent_id: str | None = None def build_memu_llm_profiles(settings: Settings) -> dict[str, Any]: - """Build LLM profiles for memu-py.""" return { "default": { "api_key": settings.OPENAI_API_KEY, @@ -30,22 +32,61 @@ def build_memu_llm_profiles(settings: Settings) -> dict[str, Any]: } -def build_memu_config(settings: Settings) -> dict[str, Any]: - """Build memu-py core configuration. +def load_prompt_overrides(path: str) -> dict[str, Any] | None: + """Load custom prompt overrides from a YAML file. - This configures memu-py to: - 1. Connect to PostgreSQL with pgvector - 2. Auto-create tables (ddl_mode: create) - 3. Use configured LLM profiles + The YAML structure should match memu-py's MemorizeConfig.memory_type_prompts: + profile: + rules: + ordinal: 30 + prompt: | + # Rules + ... + examples: + ordinal: 60 + prompt: | + # Examples + ... + event: + rules: + ordinal: 30 + prompt: | + ... """ - return { + if not path: + return None + p = Path(path) + if not p.exists(): + logger.warning("PROMPT_CONFIG_PATH=%s does not exist, using defaults", path) + return None + try: + with p.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) + if data and isinstance(data, dict): + logger.info("Loaded prompt overrides from %s (types: %s)", path, list(data.keys())) + return data + logger.warning("PROMPT_CONFIG_PATH=%s is empty or invalid, using defaults", path) + return None + except Exception: + logger.exception("Failed to load prompt overrides from %s", path) + return None + + +def build_memu_config(settings: Settings) -> dict[str, Any]: + config: dict[str, Any] = { "llm_profiles": build_memu_llm_profiles(settings), "database_config": { "metadata_store": { "provider": "postgres", - "ddl_mode": "create", # Auto-create tables + "ddl_mode": "create", "dsn": settings.DATABASE_URL, } }, "user_config": {"model": MemUUser}, } + + prompt_overrides = load_prompt_overrides(settings.PROMPT_CONFIG_PATH) + if prompt_overrides: + config["memorize_config"] = {"memory_type_prompts": prompt_overrides} + + return config diff --git a/config/prompts.yaml b/config/prompts.yaml new file mode 100644 index 0000000..f73be7b --- /dev/null +++ b/config/prompts.yaml @@ -0,0 +1,153 @@ +# memU 自定义提取 prompt 覆盖 +# 结构:memory_type → block_name → {ordinal, prompt} +# ordinal 控制拼接顺序(20=workflow, 30=rules, 60=examples) + +profile: + workflow: + ordinal: 20 + prompt: | + # Workflow + For each potential memory item, follow this strict 4-step process: + + Step 1 — SOURCE CHECK: Identify WHO said it. + - Did the USER state this as a fact about themselves? → Proceed to Step 2. + - Did the ASSISTANT say it? → DISCARD immediately. + - Is it a QUESTION from either side? → DISCARD immediately. + - Is it a command (e.g., /new, /help)? → DISCARD immediately. + - Is it a greeting (你好, hi, hello, 你好啊)? → DISCARD immediately. + + Step 2 — FACT VERIFICATION: Is this a declarative fact about the user? + - "我叫小明" → YES, fact → Proceed to Step 3. + - "我叫什么名字?" → NO, this is a question → DISCARD. + - "我不知道" (said by user) → borderline, only if user explicitly states ignorance as a trait. + - "我不确定" (said by assistant) → NO, this is assistant's state → DISCARD. + + Step 3 — PERSISTENCE CHECK: Is this stable, lasting information? + - Preferences, name, age, job, hobbies → YES. + - Temporary mood, current action, situational comment → NO, DISCARD. + + Step 4 — OUTPUT: Only items that passed ALL three steps are extracted. + If no items pass all checks, output NOTHING. Empty output is valid and preferred over noise. + + rules: + ordinal: 30 + prompt: | + # Rules + ## Absolute prohibitions (ANY violation = invalid extraction) + 1. NEVER extract anything said by the ASSISTANT as a user fact. + 2. NEVER extract questions as facts. A question contains no factual information. + 3. NEVER extract greetings, farewells, or pleasantries. + 4. NEVER extract slash commands or meta-conversation actions. + 5. NEVER extract "the user asked/wanted to know/inquired about" — asking is not a fact. + 6. NEVER extract "the user does not know X" unless the user EXPLICITLY stated "我不知道X". + If the ASSISTANT said "I don't know", that is NOT the user's trait. + + ## What to extract + - Only declarative facts the user stated about themselves: name, age, job, preferences, habits, relationships. + - Each item must be < 30 words, self-contained, and written as a declarative sentence. + - Use "The user" as the subject consistently. + - Merge similar items into one. + + examples: + ordinal: 60 + prompt: | + # Examples + Example 1 — GOOD extraction + Input: + user: 我叫小明,今年28岁,在一家互联网公司做产品经理。我喜欢编程和打篮球。 + assistant: 很高兴认识你,小明! + Output: + + The user's name is 小明Basic Information + The user is 28 years oldBasic Information + The user works as a product manager at an internet companyWork + The user likes programming and basketballPreferences + + + Example 2 — NOISE (output must be EMPTY) + Input: + user: 我叫什么名字? + assistant: 很抱歉,我不确定您的名字。您能告诉我吗? + user: /new + assistant: 好的,我们开始新的对话吧! + user: 你好啊 + assistant: 你好!有什么可以帮您的? + Output: + (empty) + Reason: "我叫什么名字?"=question. "我不确定"=assistant speech. "/new"=command. "你好啊"=greeting. ZERO facts about the user. + + Example 3 — NOISE (output must be EMPTY) + Input: + user: 你知道明天天气怎么样吗? + assistant: 我无法获取实时天气信息。 + user: 好的谢谢 + assistant: 不客气! + Output: + (empty) + Reason: All utterances are questions, assistant limitations, and pleasantries. ZERO user facts. + +event: + workflow: + ordinal: 20 + prompt: | + # Workflow + For each potential event, follow this strict 4-step process: + + Step 1 — SOURCE CHECK: Identify WHO said it and WHAT was said. + - Did the USER describe a concrete event that happened? → Proceed to Step 2. + - Did the ASSISTANT say it? → DISCARD immediately. + - Is it a QUESTION about something? → DISCARD immediately. + - Is it a command (/new, /help) or greeting (你好, hi, hello, 你好啊, hey)? → DISCARD immediately. + - Is it a response to a greeting (不客气, glad to meet you)? → DISCARD immediately. + - Is it a social pleasantry (谢谢, 好的, sure)? → DISCARD immediately. + + Step 2 — EVENT VERIFICATION: Is this a specific, significant happening? + - "I traveled to Japan last month" → YES, event → Proceed. + - "I asked about my schedule" → NO, asking is not an event → DISCARD. + - "I don't know my plans" (user said) → borderline, not an event → DISCARD. + + Step 3 — SIGNIFICANCE CHECK: Does this event matter long-term? + - Life milestones, travel, major decisions → YES. + - Routine daily activities → NO, DISCARD. + + Step 4 — OUTPUT: Only events that passed ALL three steps. + Empty output is valid and preferred over noise. + + rules: + ordinal: 30 + prompt: | + # Rules + ## Absolute prohibitions (ANY violation = invalid extraction) + 1. NEVER extract anything said by the ASSISTANT as a user event. + 2. NEVER extract questions as events. + 3. NEVER extract greetings (你好, hi, hello), farewells, commands, or social pleasantries as events. + 4. NEVER extract "the user asked about X" — asking is not an event. + 5. NEVER extract "the user does not know X" — ignorance is not an event. + 6. NEVER extract "the user greeted" or "the user said hello" — greetings are NOT events. + + ## What to extract + - Only concrete events with time, place, or participants that the user described. + - Focus on significant happenings, not routine activities. + - Each item < 50 words, self-contained, declarative sentence. + + examples: + ordinal: 60 + prompt: | + # Examples + Example 1 — GOOD extraction + Input: + user: 上周我去了东京旅游,玩了5天,吃了好多寿司。 + assistant: 听起来很棒!东京好玩吗? + Output: + + The user traveled to Tokyo for 5 days last week and ate a lot of sushiTravel + + + Example 2 — NOISE (output must be EMPTY) + Input: + user: 我明天有什么安排? + assistant: 很抱歉,我没有您明天的日程安排信息。 + user: /new + Output: + (empty) + Reason: "我明天有什么安排?"=question. "我没有信息"=assistant speech. "/new"=command. ZERO events. diff --git a/config/settings.py b/config/settings.py index 834c536..9471962 100644 --- a/config/settings.py +++ b/config/settings.py @@ -42,6 +42,11 @@ class Settings(BaseSettings): # ── Storage ── STORAGE_PATH: str = "./data/storage" + # ── Prompt Configuration ── + # Path to a YAML file containing custom prompt overrides for memu-py. + # If empty or the file does not exist, memu-py defaults are used. + PROMPT_CONFIG_PATH: str = "" + @field_validator("DATABASE_URL", mode="after") @classmethod def assemble_db_url(cls, v: str, info: ValidationInfo) -> str: