feat(A_memorix)&fix(Docker): 记忆搜索支持平台/用户筛选及界面优化

新增平台和用户账号维度的筛选能力,并对记忆/画像功能进行交互体验优化
前端:MemoryEpisodeManager 与 MemoryProfileManager 组件增加了平台和用户 ID 输入项,人员 ID 调整为可折叠的“高级”入口,新增原始 JSON 切换、人员名称展示,优化了标签文案和列表模式;搜索触发逻辑现已支持基于账号的查询和画像搜索结果的展示。
API客户端:统一记忆 API 的基础路径,请求携带凭证,增加 HTML 回退页面的检测和本地备用地址的重试机制,优化了错误提示信息。
服务端:记忆路由在返回片段和画像时,补充了来自数据库的人员名称字段,新增 /profiles/search 及兼容性接口,并在片段/画像列表接口中支持传入 platform 和 user_id 参数
其他优化:防止 SPA 路由劫持 /api 路径,入口脚本改用 venv 中的 Python,Dockerfile 中将 venv 加入 PATH 并调整了 uv sync 相关参数。
This commit is contained in:
DawnARC
2026-05-06 01:03:51 +08:00
parent aa9b437ad5
commit 85cb2b45dc
8 changed files with 545 additions and 95 deletions

View File

@@ -197,6 +197,9 @@ def _setup_static_files(app: FastAPI):
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str):
if full_path == "api" or full_path.startswith("api/"):
raise HTTPException(status_code=404, detail=t("core.not_found"))
if not full_path or full_path == "/":
response = FileResponse(static_path / "index.html", media_type="text/html")
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"

View File

@@ -9,8 +9,11 @@ from typing import Any, Optional
import tomlkit
from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Query, UploadFile
from pydantic import BaseModel, Field
from sqlmodel import col, select
from src.A_memorix.host_service import a_memorix_host_service
from src.common.database.database import get_db_session
from src.common.database.database_model import PersonInfo
from src.person_info.person_info import resolve_person_id_for_memory
from src.services.memory_service import MemorySearchResult, memory_service
from src.webui.dependencies import require_auth
@@ -298,22 +301,49 @@ async def _episode_list(
limit: int,
source: str,
person_id: str,
platform: str,
user_id: str,
time_start: float | None,
time_end: float | None,
) -> dict:
return await memory_service.episode_admin(
clean_person_id = str(person_id or "").strip()
if not clean_person_id and str(platform or "").strip() and str(user_id or "").strip():
clean_person_id = resolve_person_id_for_memory(
platform=str(platform or "").strip(),
user_id=str(user_id or "").strip(),
strict_known=False,
)
payload = await memory_service.episode_admin(
action="list",
query=query,
limit=limit,
source=source,
person_id=person_id,
person_id=clean_person_id,
time_start=time_start,
time_end=time_end,
)
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return payload
items = []
for item in payload["items"]:
if not isinstance(item, dict):
items.append(item)
continue
items.append(_enrich_episode_person_name(item))
payload = dict(payload)
payload["items"] = items
return payload
async def _episode_get(episode_id: str) -> dict:
return await memory_service.episode_admin(action="get", episode_id=episode_id)
payload = await memory_service.episode_admin(action="get", episode_id=episode_id)
if isinstance(payload, dict) and isinstance(payload.get("episode"), dict):
payload = dict(payload)
payload["episode"] = _enrich_episode_person_name(payload["episode"])
return payload
async def _episode_rebuild(payload: EpisodeRebuildRequest) -> dict:
@@ -362,8 +392,118 @@ async def _profile_query(
)
def _get_person_name_for_person_id(person_id: str) -> str:
clean_person_id = str(person_id or "").strip()
if not clean_person_id:
return ""
try:
with get_db_session(auto_commit=False) as session:
statement = select(PersonInfo.person_name).where(col(PersonInfo.person_id) == clean_person_id).limit(1)
person_name = session.exec(statement).first()
return str(person_name or "").strip()
except Exception:
return ""
def _enrich_episode_person_name(item: dict) -> dict:
enriched = dict(item)
item_person_id = str(enriched.get("person_id", "") or "").strip()
participants = enriched.get("participants")
if not item_person_id and isinstance(participants, list):
for participant in participants:
if isinstance(participant, dict):
candidate = str(participant.get("person_id", "") or participant.get("id", "") or "").strip()
else:
candidate = str(participant or "").strip()
if candidate:
item_person_id = candidate
break
enriched["person_id"] = item_person_id
enriched["person_name"] = _get_person_name_for_person_id(item_person_id)
return enriched
async def _profile_list(limit: int) -> dict:
return await memory_service.profile_admin(action="list", limit=limit)
payload = await memory_service.profile_admin(action="list", limit=limit)
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return payload
items = []
for item in payload["items"]:
if not isinstance(item, dict):
items.append(item)
continue
enriched = dict(item)
person_id = str(enriched.get("person_id", "") or "").strip()
enriched["person_name"] = _get_person_name_for_person_id(person_id)
items.append(enriched)
payload = dict(payload)
payload["items"] = items
return payload
async def _profile_search(
*,
person_id: str,
person_keyword: str,
platform: str,
user_id: str,
limit: int,
) -> dict:
clean_person_id = str(person_id or "").strip()
if not clean_person_id and str(platform or "").strip() and str(user_id or "").strip():
clean_person_id = resolve_person_id_for_memory(
platform=str(platform or "").strip(),
user_id=str(user_id or "").strip(),
strict_known=False,
)
payload = await _profile_list(max(limit, 200))
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return payload
keyword = str(person_keyword or "").strip().lower()
def _matches(item: dict) -> bool:
if clean_person_id and str(item.get("person_id", "") or "").strip() != clean_person_id:
return False
if not keyword:
return True
override = item.get("manual_override")
override_text = ""
if isinstance(override, dict):
override_text = str(override.get("override_text", "") or override.get("text", "") or "")
elif isinstance(override, str):
override_text = override
haystack = "\n".join(
[
str(item.get("person_id", "") or ""),
str(item.get("person_name", "") or ""),
str(item.get("profile_text", "") or ""),
str(item.get("source_note", "") or ""),
override_text,
]
).lower()
return keyword in haystack
items = [item for item in payload["items"] if isinstance(item, dict) and _matches(item)]
items = items[:limit]
return {
"success": True,
"items": items,
"count": len(items),
"query": {
"person_id": clean_person_id,
"person_keyword": person_keyword,
"platform": platform,
"user_id": user_id,
},
}
async def _profile_set_override(payload: ProfileOverrideRequest) -> dict:
@@ -813,6 +953,8 @@ async def list_memory_episodes(
limit: int = Query(20, ge=1, le=200),
source: str = Query(""),
person_id: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
time_start: float | None = Query(None),
time_end: float | None = Query(None),
):
@@ -821,6 +963,8 @@ async def list_memory_episodes(
limit=limit,
source=source,
person_id=person_id,
platform=platform,
user_id=user_id,
time_start=time_start,
time_end=time_end,
)
@@ -870,6 +1014,23 @@ async def list_memory_profiles(limit: int = Query(50, ge=1, le=200)):
return await _profile_list(limit)
@router.get("/profiles/search")
async def search_memory_profiles(
person_id: str = Query(""),
person_keyword: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
limit: int = Query(50, ge=1, le=200),
):
return await _profile_search(
person_id=person_id,
person_keyword=person_keyword,
platform=platform,
user_id=user_id,
limit=limit,
)
@router.post("/profiles/override")
async def set_memory_profile_override(payload: ProfileOverrideRequest):
return await _profile_set_override(payload)
@@ -1289,6 +1450,8 @@ async def compat_list_episodes(
limit: int = Query(20, ge=1, le=200),
source: str = Query(""),
person_id: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
time_start: float | None = Query(None),
time_end: float | None = Query(None),
):
@@ -1297,6 +1460,8 @@ async def compat_list_episodes(
limit=limit,
source=source,
person_id=person_id,
platform=platform,
user_id=user_id,
time_start=time_start,
time_end=time_end,
)
@@ -1346,6 +1511,23 @@ async def compat_profile_list(limit: int = Query(50, ge=1, le=200)):
return await _profile_list(limit)
@compat_router.get("/person_profile/search")
async def compat_profile_search(
person_id: str = Query(""),
person_keyword: str = Query(""),
platform: str = Query(""),
user_id: str = Query(""),
limit: int = Query(50, ge=1, le=200),
):
return await _profile_search(
person_id=person_id,
person_keyword=person_keyword,
platform=platform,
user_id=user_id,
limit=limit,
)
@compat_router.post("/person_profile/override")
async def compat_set_profile_override(payload: ProfileOverrideRequest):
return await _profile_set_override(payload)