diff --git a/app/core/config.py b/app/core/config.py index a97049c..93d529a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -150,6 +150,11 @@ def get_profile_helper_root() -> Path: return _libs_root() / "profile_helper" +def get_profile_helper_profiles_dir() -> Path: + """Return workspace-backed profile helper profiles directory.""" + return get_workspace_base() / "profile_helper" / "profiles" + + def get_prompts_dir() -> Path: """Return prompts directory: builtin libs/prompts/ > primary (mount) > app/prompts/. Mount only supplement.""" builtin = get_libs_builtin_root() diff --git a/app/services/profile_helper/agent.py b/app/services/profile_helper/agent.py index 100e501..f4beb03 100644 --- a/app/services/profile_helper/agent.py +++ b/app/services/profile_helper/agent.py @@ -1,8 +1,11 @@ """LLM tool calling + agentic loop for profile helper.""" + import json +from datetime import date from app.services.profile_helper.llm_client import create_client, get_default_model from app.services.profile_helper.prompts import META_SYSTEM_PROMPT +from app.services.profile_helper.sessions import save_forum_profile, save_profile from app.services.profile_helper.tools import ( list_doc_names, list_skill_names, @@ -10,6 +13,7 @@ read_skill, ) + def _build_tools() -> list[dict]: return [ { @@ -52,7 +56,7 @@ def _build_tools() -> list[dict]: "type": "function", "function": { "name": "read_profile", - "description": "获取当前会话中的画像内容。每次开始任务前先调用,了解当前填写进度和采集阶段。", + "description": "获取当前会话中的科研数字分身内容。每次开始任务前先调用,了解当前填写进度和采集阶段。", "parameters": {"type": "object", "properties": {}}, }, }, @@ -60,13 +64,13 @@ def _build_tools() -> list[dict]: "type": "function", "function": { "name": "write_profile", - "description": "将发展画像内容写入会话。采集到数据后必须调用此工具保存,不要只在对话中展示而不保存。", + "description": "将科研数字分身内容写入会话并同步保存到 profiles 目录。创建和更新过程中每获得一轮可保存信息后都应立即调用此工具。", "parameters": { "type": "object", "properties": { "content": { "type": "string", - "description": "完整的发展画像 Markdown 内容", + "description": "完整的科研数字分身 Markdown 内容", } }, "required": ["content"], @@ -77,13 +81,13 @@ def _build_tools() -> list[dict]: "type": "function", "function": { "name": "write_forum_profile", - "description": "将论坛画像(数字分身)写入会话。当用户确认「生成论坛画像」并完成隐私设置后,用此工具保存论坛画像内容。", + "description": "将他山论坛分身写入会话并同步保存到 profiles 目录。当用户确认生成后,用此工具保存内容。", "parameters": { "type": "object", "properties": { "content": { "type": "string", - "description": "完整的论坛画像 Markdown(Identity/Expertise/Thinking Style/Discussion Style 四节格式)", + "description": "完整的他山论坛分身 Markdown(Identity/Expertise/Thinking Style/Discussion Style 四节格式)", } }, "required": ["content"], @@ -94,7 +98,7 @@ def _build_tools() -> list[dict]: def _execute_tool(name: str, args: dict, session: dict) -> str: - """Execute a single tool, return result string.""" + """Execute a single tool and return its result.""" if name == "read_skill": return read_skill(args.get("skill_name", "")) if name == "read_doc": @@ -103,12 +107,12 @@ def _execute_tool(name: str, args: dict, session: dict) -> str: return session["profile"] if name == "write_profile": content = args.get("content", "") - session["profile"] = content - return f"已写入发展画像,共 {len(content)} 字符。" + path = save_profile(session, content) + return f"已写入科研数字分身并保存到 {path.name},共 {len(content)} 字符。" if name == "write_forum_profile": content = args.get("content", "") - session["forum_profile"] = content - return f"已写入论坛画像,共 {len(content)} 字符。" + path = save_forum_profile(session, content) + return f"已写入他山论坛分身并保存到 {path.name},共 {len(content)} 字符。" return f"未知工具: {name}" @@ -130,6 +134,11 @@ def run_agent( return model = model or get_default_model() + today_str = date.today().strftime("%Y-%m-%d") + system_content = ( + META_SYSTEM_PROMPT + + f"\n\n**当前日期**:{today_str}(写入画像时,创建时间、最后更新、unnamed 文件名等请使用此日期)" + ) messages = session["messages"].copy() messages.append({"role": "user", "content": user_message}) @@ -137,7 +146,7 @@ def run_agent( for _ in range(max_iterations): response = client.chat.completions.create( model=model, - messages=[{"role": "system", "content": META_SYSTEM_PROMPT}] + messages, + messages=[{"role": "system", "content": system_content}] + messages, tools=_build_tools(), tool_choice="auto", ) diff --git a/app/services/profile_helper/prompts.py b/app/services/profile_helper/prompts.py index 6c8f67a..f452e13 100644 --- a/app/services/profile_helper/prompts.py +++ b/app/services/profile_helper/prompts.py @@ -1,19 +1,21 @@ """Meta system prompt for profile helper agent.""" -META_SYSTEM_PROMPT = """# 科研数字分身助手 -你是「他山画像系统」的科研数字分身采集助手。你的核心任务是通过结构化对话,帮助科研人员建立、完善并维护他们的多维度发展画像。 +META_SYSTEM_PROMPT = """# 科研数字分身采集助手 + +你是「他山数字分身系统」的科研数字分身采集助手。你的核心任务是通过结构化对话,帮助科研人员建立、完善并维护他们的多维度科研数字分身。 ## 隐私与安全声明(必读) 在对话开始或用户首次询问时,主动告知以下安全细则: -- **用户画像仅在本次对话中临时生成**,系统不会在任何位置保存该画像或您的任何隐私信息 -- 您可以自行下载并本地保存 +- 您在本系统中提供的所有信息仅用于构建和更新您的数字分身。平台不会向任何第三方泄露您的数据,也不会将您的数据用于模型训练或其他用途。 +- 您的数字分身仅在平台内部运行,用于与系统中的其他智能体进行信息交流与协作,不会在平台之外使用。 +- 您可以自行决定该数字分身是否公开。当选择公开时,其他用户在发起讨论或协作任务时可以选择您的数字分身参与;当选择不公开时,该数字分身仅对您本人可见和使用。 ## 角色定位 - 语言:全程使用**中文**,语气专业、温暖、不评判 -- 身份:既是采访者(问问题),也是分析师(解读数据),也是记录者(写入画像) -- 画像数据存储在会话中,用户可随时下载为 .md 文件 +- 身份:既是采访者(问问题),也是分析师(解读数据),也是记录者(写入数字分身) +- 数字分身数据存储在会话中,并会同步保存到 `profiles/` 目录,用户也可随时下载为 .md 文件 ## 画像维度说明 @@ -32,7 +34,7 @@ | 用户说的话 | 应调用的 read_skill | |:---|:---| -| 「帮我建立画像」「新建档案」「开始收集信息」 | collect-basic-info | +| 「帮我建立分身」「新建档案」「开始收集信息」 | collect-basic-info | | 「我想填量表」「用标准量表测量」「施测」 | 根据画像中哪个量表未完成,依次 administer-ams、administer-rcss、administer-mini-ipip | | 「帮我推断」「不想填量表」「快速估算」 | infer-profile-dimensions | | 「查看画像」「审核」「给我看结果」 | review-profile | @@ -40,26 +42,28 @@ | 「新增维度」「删除维度」「修改维度」「调整画像结构」 | modify-profile-schema | | 「从 AI 记忆导入」「根据 ChatGPT 记忆」「我有 AI 记忆」「生成提示词」 | generate-ai-memory-prompt | | 「整合 AI 回复」「导入 AI 的回答」「把这段内容写进画像」(用户粘贴了 AI 的回复内容) | import-ai-memory | -| 「生成论坛画像」「数字分身」「导出论坛档案」 | generate-forum-profile | +| 「生成论坛画像」「生成他山论坛分身」「他山论坛分身」「数字分身」「导出论坛档案」 | generate-forum-profile | ## 工具使用说明 - **read_skill(skill_name)**:获取具体任务的操作指南。执行任务前必须先调用此工具。 - **read_doc(doc_name)**:获取量表原题等参考文档。施测时用 read_doc 读取题目,如 academic-motivation-scale、mini-ipip-scale、researcher-cognitive-style。 - **read_profile()**:获取当前会话中的画像内容。每次开始任务前先调用,了解当前填写进度。 -- **write_profile(content)**:将发展画像内容写入会话。采集到数据后必须调用此工具保存。 -- **write_forum_profile(content)**:将论坛画像(数字分身)写入会话。当用户确认「生成论坛画像」并完成隐私设置后,用此工具保存论坛画像(Identity/Expertise/Thinking Style/Discussion Style 四节格式)。 +- **write_profile(content)**:将科研数字分身内容写入会话,并同步保存到 `profiles/` 目录。创建和更新过程中,每获得一轮可保存信息后都应立即调用。 +- **write_forum_profile(content)**:将他山论坛分身写入会话,并同步保存到 `profiles/` 目录。当用户确认「生成他山论坛分身」并完成隐私设置后,用此工具保存(Identity/Expertise/Thinking Style/Discussion Style 四节格式)。 ## 通用操作规则 -1. **每次开始任务前**,先调用 read_profile 了解当前画像状态和采集阶段。 -2. **写入数据时**:发展画像用 write_profile,论坛画像(数字分身)用 write_forum_profile,不要只在对话中展示而不保存。 +1. **每次开始任务前**,先调用 read_profile 了解当前数字分身状态和采集阶段。 +2. **写入数据时**:科研数字分身用 write_profile,他山论坛分身用 write_forum_profile,不要只在对话中展示而不保存;在创建和更新过程中要边采集边保存。 3. **推断数据** 须标注 `(AI推断,置信度:高/中/低)`,与用户实测数据区分。 -4. 若用户没有提供姓名,画像标题使用 `unnamed-[日期]`。 +4. **不要在对话开始时询问姓名或标识**。AI 记忆导入等流程直接从任务内容开始;仅在需要保存画像(write_profile)时,若尚未确定姓名,再单独询问用户。若用户未提供,数字分身标题使用 `unnamed-[日期]`。 5. **当前需求**是用户自述字段,不使用 AI 推断标注,但综合解读中应结合此信息给出贴近现实的近期建议。 -6. **AI 记忆导入安全原则**: - - 生成提示词时,只询问与画像维度直接相关的信息,**严禁**涉及财务、健康、家庭关系、政治观点等隐私内容 - - 来自 AI 记忆的所有信息,**必须经用户逐条确认**后才能写入画像,不得自动批量写入 +6. **问答话术**:提问时使用「依次回答」,不要使用「请随意回答」。 +7. **AI 记忆导入安全原则**: + - 生成提示词时,只询问与画像维度直接相关的信息,**严禁**涉及财务、健康、家庭关系、政治观点等隐私内容;**不询问用户使用哪个 AI 工具**,提示词对所有 AI 平台通用 + - 有据可查且无冲突的条目:内部整合后直接写入,**不向用户展示**;只有模糊、冲突或缺关键字段时才以**选择题**形式向用户提问,必要时再用填空或开放问答,并给出回答样例 + - 完整画像仅在用户「查看画像」「审核」时**一次性展示**供确认 - AI 记忆来源的数据须标注 `(来源:AI记忆,已用户确认)` - 当 AI 记忆信息与用户已填写数据存在冲突时,**以用户自述为准** """ diff --git a/app/services/profile_helper/sessions.py b/app/services/profile_helper/sessions.py index ab1760e..3f1965c 100644 --- a/app/services/profile_helper/sessions.py +++ b/app/services/profile_helper/sessions.py @@ -1,25 +1,112 @@ -"""In-memory session management with lightweight cleanup.""" +"""In-memory session management with cleanup and profile auto-save.""" + +from __future__ import annotations + import os +import re import time import uuid +from datetime import date +from pathlib import Path +from app.core.config import get_profile_helper_profiles_dir from app.services.profile_helper.tools import load_template _sessions: dict[str, dict] = {} SESSION_TTL_SECONDS = max(60, int(os.getenv("PROFILE_HELPER_SESSION_TTL_SECONDS", "3600"))) SESSION_MAX_COUNT = max(10, int(os.getenv("PROFILE_HELPER_SESSION_MAX_COUNT", "1000"))) +PLACEHOLDER_IDENTIFIERS = {"[姓名/标识]", "姓名/标识"} +PROFILE_TITLE_PREFIXES = ( + "# 科研人员画像 — ", + "# 科研数字分身 — ", +) def _now() -> float: return time.time() -def _new_session() -> dict: +def _load_template_with_date() -> str: + today_str = date.today().strftime("%Y-%m-%d") + return load_template().replace("YYYY-MM-DD", today_str) + + +def _today_unnamed() -> str: + return f"unnamed-{date.today().strftime('%Y-%m-%d')}" + + +def _sanitize_identifier(identifier: str) -> str: + cleaned = identifier.strip() + if cleaned in PLACEHOLDER_IDENTIFIERS or not cleaned: + return _today_unnamed() + cleaned = re.sub(r'[\\/:*?"<>|]+', "-", cleaned) + cleaned = re.sub(r"\s+", " ", cleaned).strip(" .") + return cleaned or _today_unnamed() + + +def _extract_profile_identifier(content: str) -> str: + for line in content.splitlines(): + stripped = line.strip() + if not stripped: + continue + for prefix in PROFILE_TITLE_PREFIXES: + if stripped.startswith(prefix): + return _sanitize_identifier(stripped[len(prefix) :]) + if stripped.startswith("# "): + return _sanitize_identifier(stripped[2:]) + break + return _today_unnamed() + + +def _normalize_existing_path(path_value: str | None) -> Path | None: + if not path_value: + return None + return Path(path_value) + + +def _profiles_dir() -> Path: + return get_profile_helper_profiles_dir() + + +def _session_suffix(session: dict) -> str: + sid = session.get("session_id") or "" + if sid: + return sid.replace("-", "")[:8] + return uuid.uuid4().hex[:8] + + +def _target_profile_path(content: str, session: dict) -> Path: + identifier = _extract_profile_identifier(content) + suffix = _session_suffix(session) + return _profiles_dir() / f"{identifier}-{suffix}.md" + + +def _target_forum_profile_path(session: dict) -> Path: + profile_path = _normalize_existing_path(session.get("profile_path")) + if not profile_path: + profile_path = _target_profile_path(session.get("profile", ""), session) + return profile_path.with_name(f"{profile_path.stem}-论坛画像.md") + + +def _relocate_file_if_needed(current_path: Path | None, target_path: Path) -> None: + if not current_path or current_path == target_path or not current_path.exists(): + return + if target_path.exists(): + current_path.unlink() + return + target_path.parent.mkdir(parents=True, exist_ok=True) + current_path.rename(target_path) + + +def _new_session(session_id: str) -> dict: now = _now() return { + "session_id": session_id, "messages": [], - "profile": load_template(), + "profile": _load_template_with_date(), "forum_profile": "", + "profile_path": None, + "forum_profile_path": None, "created_at": now, "updated_at": now, } @@ -51,17 +138,67 @@ def _cleanup() -> None: _sessions.pop(sid, None) +def save_profile(session: dict, content: str) -> Path: + """Persist the development profile to disk and session memory.""" + profiles_dir = _profiles_dir() + profiles_dir.mkdir(parents=True, exist_ok=True) + + target_path = _target_profile_path(content, session) + current_path = _normalize_existing_path(session.get("profile_path")) + _relocate_file_if_needed(current_path, target_path) + target_path.write_text(content, encoding="utf-8") + + session["profile"] = content + session["profile_path"] = str(target_path) + + forum_content = session.get("forum_profile", "") + if forum_content: + forum_target_path = _target_forum_profile_path(session) + forum_current_path = _normalize_existing_path(session.get("forum_profile_path")) + _relocate_file_if_needed(forum_current_path, forum_target_path) + forum_target_path.write_text(forum_content, encoding="utf-8") + session["forum_profile_path"] = str(forum_target_path) + + _touch(session) + return target_path + + +def save_forum_profile(session: dict, content: str) -> Path: + """Persist the forum profile to disk and session memory.""" + profiles_dir = _profiles_dir() + profiles_dir.mkdir(parents=True, exist_ok=True) + + profile_content = session.get("profile", "") + if profile_content: + save_profile(session, profile_content) + + target_path = _target_forum_profile_path(session) + current_path = _normalize_existing_path(session.get("forum_profile_path")) + _relocate_file_if_needed(current_path, target_path) + target_path.write_text(content, encoding="utf-8") + + session["forum_profile"] = content + session["forum_profile_path"] = str(target_path) + _touch(session) + return target_path + + def get_or_create(session_id: str | None = None) -> tuple[str, dict]: """Get or create session. Returns (session_id, session_data).""" _cleanup() if session_id and session_id in _sessions: s = _sessions[session_id] + s["session_id"] = session_id if "forum_profile" not in s: s["forum_profile"] = "" + if "profile_path" not in s: + s["profile_path"] = None + if "forum_profile_path" not in s: + s["forum_profile_path"] = None _touch(s) return session_id, s sid = session_id or str(uuid.uuid4()) - _sessions[sid] = _new_session() + _sessions[sid] = _new_session(sid) _cleanup() return sid, _sessions[sid] @@ -80,5 +217,5 @@ def get(session_id: str) -> dict | None: def reset(session_id: str) -> dict: """Reset session: clear messages and restore template profile.""" - _sessions[session_id] = _new_session() + _sessions[session_id] = _new_session(session_id) return _sessions[session_id] diff --git a/app/services/profile_helper/tools.py b/app/services/profile_helper/tools.py index 1e72866..dec48f1 100644 --- a/app/services/profile_helper/tools.py +++ b/app/services/profile_helper/tools.py @@ -1,4 +1,4 @@ -"""Tools: read_skill, read_doc, read_profile, write_profile.""" +"""Tools: load profile-helper skills, docs, and template assets.""" from __future__ import annotations @@ -31,37 +31,71 @@ ] -def _candidate_roots() -> list[Path]: - primary = get_profile_helper_root() - backend_root = Path(__file__).resolve().parents[3] - local = backend_root / "libs" / "profile_helper" - builtin = Path("/app/libs_builtin/profile_helper") - candidates = [primary, builtin, local] +def _dedupe_paths(paths: list[Path]) -> list[Path]: deduped: list[Path] = [] - for path in candidates: - path = path.resolve() - if path not in deduped: - deduped.append(path) + for path in paths: + resolved = path.resolve() + if resolved not in deduped: + deduped.append(resolved) return deduped -def _resolve_profile_helper_root() -> Path: - for root in _candidate_roots(): - if root.exists() and root.is_dir(): - return root - return _candidate_roots()[0] +def _project_root() -> Path: + return Path(__file__).resolve().parents[4] + + +def _external_helper_repo() -> Path: + return _project_root().parent / "tashan-profile-helper" + + +def _candidate_skill_dirs() -> list[Path]: + primary = get_profile_helper_root() / "skills" + builtin = Path("/app/libs_builtin/profile_helper/skills") + local = _project_root() / "backend" / "libs" / "profile_helper" / "skills" + external = _external_helper_repo() / "web" / "skills" + return _dedupe_paths([external, primary, builtin, local]) + + +def _candidate_doc_dirs() -> list[Path]: + primary = get_profile_helper_root() / "docs" + builtin = Path("/app/libs_builtin/profile_helper/docs") + local = _project_root() / "backend" / "libs" / "profile_helper" / "docs" + external = _external_helper_repo() / "doc" + return _dedupe_paths([external, primary, builtin, local]) + + +def _candidate_template_paths() -> list[Path]: + primary = get_profile_helper_root() / "_template.md" + builtin = Path("/app/libs_builtin/profile_helper/_template.md") + local = _project_root() / "backend" / "libs" / "profile_helper" / "_template.md" + external = _external_helper_repo() / "profiles" / "_template.md" + return _dedupe_paths([external, primary, builtin, local]) + + +def _resolve_existing_dir(candidates: list[Path]) -> Path: + for path in candidates: + if path.exists() and path.is_dir(): + return path + return candidates[0] + + +def _resolve_existing_file(candidates: list[Path]) -> Path: + for path in candidates: + if path.exists() and path.is_file(): + return path + return candidates[0] def _skills_dir() -> Path: - return _resolve_profile_helper_root() / "skills" + return _resolve_existing_dir(_candidate_skill_dirs()) def _docs_dir() -> Path: - return _resolve_profile_helper_root() / "docs" + return _resolve_existing_dir(_candidate_doc_dirs()) def _template_path() -> Path: - return _resolve_profile_helper_root() / "_template.md" + return _resolve_existing_file(_candidate_template_paths()) def list_skill_names() -> list[str]: diff --git a/libs/profile_helper/_template.md b/libs/profile_helper/_template.md index e4d5383..adf158c 100644 --- a/libs/profile_helper/_template.md +++ b/libs/profile_helper/_template.md @@ -1,4 +1,4 @@ -# 科研数字分身 — [姓名/标识] +# 科研人员画像 — [姓名/标识] ## 元信息 diff --git a/libs/profile_helper/docs/tashan-profile-examples.md b/libs/profile_helper/docs/tashan-profile-examples.md index 24efa05..9dddd30 100644 --- a/libs/profile_helper/docs/tashan-profile-examples.md +++ b/libs/profile_helper/docs/tashan-profile-examples.md @@ -1,6 +1,6 @@ -# 虚拟科研数字分身示例 +# 虚拟科研人员画像示例 -> 本文档为基于「科研数字分身系统」框架构造的虚拟人物画像示例,供系统设计与测试参考。 +> 本文档为基于「科研发展画像系统」框架构造的虚拟人物画像示例,供系统设计与测试参考。 > 学术动机数据参照 AMS-GSR 28,认知风格参照 RCSS,人格数据参照 Mini-IPIP。 --- diff --git a/libs/profile_helper/docs/tashan-profile-outline.md b/libs/profile_helper/docs/tashan-profile-outline.md index 60f1cfa..286fbe5 100644 --- a/libs/profile_helper/docs/tashan-profile-outline.md +++ b/libs/profile_helper/docs/tashan-profile-outline.md @@ -1,4 +1,4 @@ -# 科研数字分身系统 +# 科研发展画像系统 ## 一、基础身份 - **研究阶段**:博士生 / 博后 / 青椒 / PI diff --git a/libs/profile_helper/skills/administer-mini-ipip/SKILL.md b/libs/profile_helper/skills/administer-mini-ipip/SKILL.md index d2ab357..c5da8fc 100644 --- a/libs/profile_helper/skills/administer-mini-ipip/SKILL.md +++ b/libs/profile_helper/skills/administer-mini-ipip/SKILL.md @@ -35,17 +35,6 @@ description: 施测大五人格量表(Mini-IPIP,20题),评估外向性 | 9 | 我大部分时间都很放松。 | | 10 | 我对抽象概念不感兴趣。 | -**重要**:每批题目呈现后,必须在消息末尾追加以下格式,以便前端渲染 1-5 分选择按钮: -``` -:::scale -min:1 -max:5 -1 | 我是聚会中的焦点人物。 -2 | 我同情他人的感受。 -...(本批 10 题的完整题目,每行格式为「题号 | 题目文字」) -::: -``` - ### 第 2 批(题目 11-20) | 题号 | 题目 | diff --git a/libs/profile_helper/skills/collect-basic-info/SKILL.md b/libs/profile_helper/skills/collect-basic-info/SKILL.md index 1db4059..c80392e 100644 --- a/libs/profile_helper/skills/collect-basic-info/SKILL.md +++ b/libs/profile_helper/skills/collect-basic-info/SKILL.md @@ -1,34 +1,45 @@ --- name: collect-basic-info -description: 采集科研数字分身的基础信息(研究阶段、学科领域、方法范式、技术能力、科研流程能力)。当用户开始建立画像、或基础信息尚未填写时使用。 +description: 采集科研数字分身的基础信息(研究阶段、学科领域、方法范式、技术能力、科研流程能力)。当用户开始建立科研数字分身、或基础信息尚未填写时使用。 --- # Phase 1:基础信息采集 ## 启动步骤 -1. 询问用户姓名或标识(用于命名画像文件) -2. 检查 `profiles/[姓名].md` 是否已存在 - - 不存在 → 从 `profiles/_template.md` 复制,创建新文件 - - 已存在 → 读取文件,定位尚未填写的字段,从断点续采 -3. **询问是否使用 AI 记忆辅助建档**(仅在画像文件**新建**或基础信息**大量为空**时询问): +### 步骤一:询问是否使用 AI 记忆辅助建档 + +(仅在科研数字分身**新建**或基础信息**大量为空**时执行此步骤) + +**不要先询问姓名或标识**。直接向用户展示: ``` 在开始逐项填写之前,想问一下——你平时有没有使用过带记忆功能的 AI 工具? - (比如 ChatGPT、Claude 等,且已经有一定时间的使用记录) + (比如 ChatGPT、Claude、Gemini 等,且已经有一定时间的使用记录) - 如果有的话,我可以帮你生成一段提示词,发给那个 AI, - 让它根据对你的了解来预填画像——这样能节省不少时间。 + 如果有的话,我可以帮你生成一段提示词,发给你使用的 AI, + 让它根据对你的了解来预填科研数字分身——这样能节省不少时间。 + 若你使用多个 AI,可把同一提示词都发一遍,再把回复依次粘贴回来,我会帮你整合。 A. 有,我想先从 AI 记忆中提取信息 B. 没有,或者不需要,直接开始填写 ``` - - 选 **A**:读取并执行 `.cursor/skills/generate-ai-memory-prompt/SKILL.md`, - 完成 AI 记忆导入后,再回到此 Skill 补充剩余空白字段 - - 选 **B**:继续下一步 + - 选 **A**:读取并执行 `generate-ai-memory-prompt` Skill,**不询问姓名**;完成 AI 记忆导入后(姓名会在保存时由 import-ai-memory 询问),再回到此 Skill 补充剩余空白字段 + - 选 **B**:继续下一步 + +### 步骤二:收集个人标识 + +(仅在用户选择了「直接开始填写」时执行;若用户选了 AI 记忆优先,姓名在 import-ai-memory 保存时已询问) + +1. 询问用户姓名或标识(用于命名科研数字分身文件) +2. 检查 `profiles/[姓名].md` 是否已存在 + - 不存在 → 从 `profiles/_template.md` 复制,创建新文件 + - 已存在 → 读取文件,定位尚未填写的字段,从断点续采 + +### 步骤三:进入基础信息采集 -4. 告知用户采集分为两批:**基础身份** 和 **能力自评**,共约 10 分钟 +告知用户采集分为两批:**基础身份** 和 **能力自评**,共约 10 分钟 ## 第一批:基础身份(逐个提问,不要一次全问) @@ -75,7 +86,7 @@ description: 采集科研数字分身的基础信息(研究阶段、学科领 ## 当前需求采集 -说明:「最后,我想了解一下你现在实际面对的情境——这将帮助画像系统给你更贴近现实的建议。」 +说明:「最后,我想了解一下你现在实际面对的情境——这将帮助科研数字分身系统给你更贴近现实的建议。」 **Q-需求1**:「你现在每天/每周花费最多精力的事情是什么?可以列举 1–3 件,不限于科研,也包括杂事、沟通、行政等。」 - 追问(可选):「做这些事情时,你的整体感受是充实、疲惫、还是混乱?」 @@ -86,7 +97,7 @@ description: 采集科研数字分身的基础信息(研究阶段、学科领 **Q-需求3**:「如果现在有一件事可以改变,让你接下来三个月更顺,你最想改变什么?」 - 提示:「可以是一个技能、一个习惯、一段关系、一个系统,不必是宏大目标。」 -将三个问题的答案分别记录到画像文件的「三、当前需求」章节。 +将三个问题的答案分别记录到科研数字分身的「三、当前需求」章节。 ## 完成后操作 diff --git a/libs/profile_helper/skills/generate-ai-memory-prompt/SKILL.md b/libs/profile_helper/skills/generate-ai-memory-prompt/SKILL.md index 470206f..fa76363 100644 --- a/libs/profile_helper/skills/generate-ai-memory-prompt/SKILL.md +++ b/libs/profile_helper/skills/generate-ai-memory-prompt/SKILL.md @@ -1,6 +1,6 @@ --- name: generate-ai-memory-prompt -description: 根据当前画像状态,生成结构化提示词,供用户提交给 ChatGPT 等带记忆功能的 AI,从中提取与画像构建相关的信息。 +description: 根据当前科研数字分身状态,生成结构化提示词,供用户提交给 ChatGPT 等带记忆功能的 AI,从中提取与画像构建相关的信息。不询问用户使用哪个 AI 工具,提示词对所有平台通用。 --- # 生成 AI 记忆提取提示词 @@ -9,28 +9,16 @@ description: 根据当前画像状态,生成结构化提示词,供用户提 此 Skill 在以下两种场景被调用: -- **新建画像**:在 `collect-basic-info` 流程开始时,询问用户是否已使用过带记忆功能的 AI(如 ChatGPT、Claude 等),如有则先运行此 Skill -- **已有画像**:用户主动说「从 AI 记忆导入」「根据 AI 记忆丰富画像」「我有 ChatGPT 记忆」等 +- **新建科研数字分身**:在 `collect-basic-info` 流程开始时,询问用户是否已使用过带记忆功能的 AI(如 ChatGPT、Claude 等),如有则先运行此 Skill +- **已有数字分身**:用户主动说「从 AI 记忆导入」「根据 AI 记忆丰富画像」「我有 ChatGPT 记忆」等 --- -## 步骤一:了解用户的 AI 使用情况 +## 步骤一:判断画像状态,确定提示词类型 -向用户询问: +**不询问用户使用哪个 AI 工具**。提示词对所有 AI 平台(ChatGPT、Claude、Gemini 等)通用。 -``` -你在哪个 AI 工具上积累了比较多的使用记录?(如 ChatGPT、Claude、Gemini 等) - -请注意:我们只会提取与「科研数字分身」相关的信息,不会涉及你的私人对话内容或隐私信息。 -``` - -如果用户提到多个工具,建议优先选择使用时间最长、对话记录最丰富的一个。 - ---- - -## 步骤二:判断画像状态,确定提示词类型 - -读取当前画像文件(若存在),判断哪些维度已有数据、哪些为空白: +读取当前画像(若存在),判断哪些维度已有数据、哪些为空白: | 维度 | 判断标准 | |:---|:---| @@ -41,20 +29,20 @@ description: 根据当前画像状态,生成结构化提示词,供用户提 | 学术动机(AMS) | 各维度得分是否有数值 | | 人格(Mini-IPIP) | 各维度得分是否有数值 | -- **新用户(无画像文件)**:所有维度均需提取,生成「全量提示词」 +- **新用户(无画像)**:所有维度均需提取,生成「全量提示词」 - **已有部分数据**:仅针对空白维度生成「补充提示词」,已有数据的维度仍列出以供 AI 做一致性参照 --- -## 步骤三:生成结构化提示词 +## 步骤二:生成结构化提示词 根据上一步的判断,从以下模块中选取相关部分,组合成完整提示词。 **输出格式要求(必须遵守)**: -1. 先写一句引导语:「请将下方代码块中的提示词复制后,粘贴到 ChatGPT 等 AI 的对话框中发送。」 +1. 先写一句引导语:「请将下方代码块中的提示词复制后,依次粘贴到你所使用的 AI(如 ChatGPT、Claude、Gemini 等)的对话框中发送。」 2. 紧接着用 markdown 代码块(\`\`\`markdown ... \`\`\`)包裹**完整提示词**,使提示词单独成块,方便用户一键复制。 -3. 代码块下方再附使用说明(步骤四的内容)。 +3. 代码块下方再附使用说明(步骤三的内容)。 --- @@ -63,8 +51,8 @@ description: 根据当前画像状态,生成结构化提示词,供用户提 ``` 【科研数字分身信息提取请求】 -你好!我正在使用一个科研数字分身系统(他山画像)来记录和分析我的科研状态。 -请根据你对我的了解,帮我提取以下信息。 +你好!我正在使用一个科研数字分身系统(他山数字分身系统)来记录和分析我的科研状态。 +请根据你对我的了解,**依次回答**以下问题。 ⚠️ 重要说明: 1. 请仅根据我们真实对话中已出现的信息作答,严禁推测或捏造 @@ -82,7 +70,7 @@ description: 根据当前画像状态,生成结构化提示词,供用户提 ``` 【模块 A:基础身份】 -请根据你对我的了解,回答以下问题(每项用1-2句话,不确定则写"记忆不足"): +请根据你对我的了解,**依次回答**以下问题(每项用1-2句话,不确定则写"记忆不足"): A1. 我目前处于哪个研究阶段?(博士生 / 博士后 / 青年教师 / PI / 其他) A2. 我的主要研究领域是什么?(一级学科 + 具体方向) @@ -97,7 +85,7 @@ A5. 我的学术合作圈大概是什么情况? ``` 【模块 B:科研能力】 -请根据你对我的了解,回答以下问题: +请根据你对我的了解,**依次回答**以下问题: B1. 我主要使用哪些编程语言或科研工具?熟练程度如何? B2. 我是否有代表性的学术产出(论文、开源项目、工具包等)?如有请简述。 @@ -117,7 +105,7 @@ B3. 在以下6个科研流程环节中,你观察到我哪些比较强、哪些 ``` 【模块 C:当前需求】 -请根据你对我最近对话的了解,回答以下问题: +请根据你对我最近对话的了解,**依次回答**以下问题: C1. 我最近花费最多精力的事情是什么?(包括科研以外的事务也可以提) C2. 我最近提到过哪些困扰、卡点或让我觉得"推不动"的事情? @@ -166,7 +154,7 @@ E3. 在人格层面,我给你的整体印象是?(例如:开放好奇 / ``` --- 【汇总格式要求】 -请将你的回答按以上模块分别作答,每项均注明可信度标签,并附上依据: +请将你的回答按以上模块**依次作答**,每项均注明可信度标签,并附上依据: - ✅ 有据可查:必须附上具体证据,例如「你在[日期/话题]中提到过……」或摘录你的原话 - ⚠️ 印象模糊:说明模糊程度及不确定来源,例如「隐约记得聊过某话题,但具体表述记不清」 @@ -177,17 +165,18 @@ E3. 在人格层面,我给你的整体印象是?(例如:开放好奇 / --- -## 步骤四:向用户展示提示词并给出使用说明 +## 步骤三:向用户展示提示词并给出使用说明 输出提示词后,附上说明: ``` -以上是为你生成的提示词,请将其复制并提交给你使用的 AI(如 ChatGPT)。 +以上是为你生成的提示词,适用于 ChatGPT、Claude、Gemini 等所有带记忆功能的 AI。 使用建议: 1. 直接粘贴到对话框发送即可,无需修改 -2. 如果 AI 回复"记忆不足"较多,可以追问:「你记得我跟你聊过关于[具体话题]的事情吗?」 -3. 拿到 AI 的回复后,回来告诉我,我会帮你把信息整合进画像 +2. 如果你使用多个 AI 工具,请将同一提示词分别发送给每个 AI,然后把各 AI 的回复**依次粘贴**回来,我会帮你整合 +3. 如果 AI 回复"记忆不足"较多,可以追问:「你记得我跟你聊过关于[具体话题]的事情吗?」 +4. 拿到 AI 的回复后,回来把内容粘贴给我,我会帮你整合进科研数字分身 ⚠️ 安全提示:这份提示词不会让 AI 泄露你的具体对话内容, 只是请它根据已有记忆做定向总结。即便如此,请你在查看 AI 的回复后, @@ -196,6 +185,6 @@ E3. 在人格层面,我给你的整体印象是?(例如:开放好奇 / --- -## 步骤五:等待用户返回 AI 的回复 +## 步骤四:等待用户返回 AI 的回复 -告知用户:「拿到 AI 的回复后,请把内容粘贴回来,我会读取 `import-ai-memory` Skill 来帮你完成整合。」 +告知用户:「拿到 AI 的回复后,请把内容粘贴回来(如有多个 AI 的回复,请依次粘贴),我会读取 `import-ai-memory` Skill 来帮你完成整合。」 diff --git a/libs/profile_helper/skills/generate-forum-profile/SKILL.md b/libs/profile_helper/skills/generate-forum-profile/SKILL.md index ac27862..ef9a8b1 100644 --- a/libs/profile_helper/skills/generate-forum-profile/SKILL.md +++ b/libs/profile_helper/skills/generate-forum-profile/SKILL.md @@ -1,24 +1,24 @@ --- name: generate-forum-profile -description: 将发展画像的各维度整合提取为论坛数字分身,格式为 Identity / Expertise / Thinking Style / Discussion Style 四节 Markdown,输出前先让用户确认隐私暴露范围。当用户说「生成论坛画像」「数字分身」「导出论坛档案」时使用。 +description: 将科研数字分身各维度整合提取为他山论坛分身,格式为 Identity / Expertise / Thinking Style / Discussion Style 四节 Markdown,输出前先让用户确认隐私暴露范围。当用户说「生成他山论坛分身」「论坛画像」「数字分身」「导出论坛档案」时使用。 --- -# 生成论坛数字分身 +# 生成他山论坛分身 ## 触发时机 -用户说「生成论坛画像」「数字分身」「导出论坛档案」等,希望将发展画像抽取为论坛系统可用的数字分身档案。 +用户说「生成他山论坛分身」「生成论坛画像」「数字分身」「导出论坛档案」等,希望将科研数字分身抽取为他山论坛可用的分身档案。 --- -## 步骤一:读取画像文件 +## 步骤一:读取科研数字分身 1. 确定用户姓名或标识(若用户未提供,从当前对话上下文推断,或询问) -2. 检查 `profiles/[用户名].md` 是否存在 +2. 检查当前会话中的科研数字分身或 `profiles/[用户名].md` 是否存在 3. 若不存在或画像为空,提示: ``` - 尚未找到你的发展画像。请先完成基础信息采集,建立画像后再生成论坛档案。 - 可以说「帮我建立画像」开始。 + 尚未找到你的科研数字分身。请先完成基础信息采集,建立科研数字分身后再生成他山论坛分身。 + 可以说「帮我建立分身」开始。 ``` 4. 若存在,读取完整画像内容,进入步骤二 @@ -29,8 +29,8 @@ description: 将发展画像的各维度整合提取为论坛数字分身,格 **不得跳过此步骤,不得默认暴露任何维度。** 向用户逐项展示以下确认清单: ``` -在生成论坛画像前,我需要先确认你愿意对外暴露的信息范围。 -论坛画像将作为你在讨论中的「数字分身」,对其他成员可见。 +在生成他山论坛分身前,我需要先确认你愿意对外暴露的信息范围。 +他山论坛分身将作为你在讨论中的数字身份,对其他成员可见。 请确认以下各项你是否同意包含: @@ -59,9 +59,9 @@ description: 将发展画像的各维度整合提取为论坛数字分身,格 --- -## 步骤三:按隐私设置提取并生成论坛画像 +## 步骤三:按隐私设置提取并生成他山论坛分身 -根据用户确认结果,从发展画像各维度中提取内容,映射到四节格式。**所有内容用行为描述句式,不直接引用量表分数。** +根据用户确认结果,从科研数字分身各维度中提取内容,映射到四节格式。**所有内容用行为描述句式,不直接引用量表分数。** ### 维度映射规则 @@ -121,12 +121,12 @@ description: 将发展画像的各维度整合提取为论坛数字分身,格 ## 步骤四:输出与预览 -1. 将生成的论坛画像以 Markdown 格式在对话中完整展示 -2. 说明输出将保存为 `profiles/[用户名]-论坛画像.md` +1. 将生成的他山论坛分身以 Markdown 格式在对话中完整展示 +2. 说明输出将保存为 `profiles/[用户名]-他山论坛分身.md`(或通过 write_forum_profile 写入会话) 3. 询问用户: ``` - 以上是为你生成的论坛画像预览。请确认: - A. 满意,保存到文件 + 以上是为你生成的他山论坛分身预览。请确认: + A. 满意,保存 B. 需要修改某节(请说明哪一节以及修改意见) ``` @@ -136,12 +136,9 @@ description: 将发展画像的各维度整合提取为论坛数字分身,格 用户确认满意后: -1. 使用 `Write` 工具将完整 Markdown 保存至 `profiles/[用户名]-论坛画像.md` -2. 在 `profiles/[用户名].md` 的 `## 八、审核记录` 表格中追加一行: - ``` - | [今日日期] | 论坛画像导出 | 用户确认隐私范围后生成 | 已保存至 [用户名]-论坛画像.md | - ``` +1. 使用 `write_forum_profile` 工具(Web 模式)或 `Write` 工具将完整 Markdown 保存 +2. 在科研数字分身的审核记录中追加一条导出记录 3. 告知用户: ``` - 论坛画像已保存至 profiles/[用户名]-论坛画像.md,可直接用于他山论坛的数字分身配置。 + 他山论坛分身已保存,可直接用于他山论坛的数字分身配置。 ``` diff --git a/libs/profile_helper/skills/import-ai-memory/SKILL.md b/libs/profile_helper/skills/import-ai-memory/SKILL.md index ac18920..7503876 100644 --- a/libs/profile_helper/skills/import-ai-memory/SKILL.md +++ b/libs/profile_helper/skills/import-ai-memory/SKILL.md @@ -1,170 +1,116 @@ --- name: import-ai-memory -description: 解析用户从 ChatGPT 等 AI 获取的记忆回复,与现有画像对比后,对有据可查的内容整体确认一次,对模糊/冲突/当前需求条目逐条确认,将信息整合进画像文件。 +description: 解析用户从 ChatGPT 等 AI 获取的记忆回复(支持多个 AI 依次粘贴),与现有画像对比后,有据可查的条目内部整合直接写入不展示;模糊/冲突/当前需求条目以选择题形式逐条确认,必要时填空或问答并给样例;完整画像仅在用户「查看画像」「审核」时一次性展示。 --- # AI 记忆导入与整合 ## 触发时机 -用户将 AI(如 ChatGPT)根据提示词返回的回复粘贴过来,请求整合进画像。 +用户将 AI(如 ChatGPT、Claude、Gemini)根据提示词返回的回复粘贴过来,请求整合进科研数字分身。若用户使用多个 AI 工具,会依次粘贴多段回复。 + +**重要**:**不要在流程开始时询问用户姓名或标识**。直接从步骤一解析 AI 回复开始。 --- ## 步骤一:解析 AI 回复内容 -读取用户粘贴的 AI 回复,按以下方式解析: +读取用户粘贴的 AI 回复(可能包含多段,来自不同 AI),按以下方式解析: -1. 识别回复中涉及的模块(A 基础身份 / B 能力 / C 当前需求 / D 认知风格 / E 动机人格) -2. 对每条信息,记录 AI 标注的可信度标签及依据(若有): +1. 若用户粘贴了多段回复,按粘贴顺序依次解析,合并同字段信息(后粘贴的补充或覆盖先前的,冲突时标记) +2. 识别回复中涉及的模块(A 基础身份 / B 能力 / C 当前需求 / D 认知风格 / E 动机人格) +3. 对每条信息,记录 AI 标注的可信度标签及依据(若有): - ✅ 有据可查(含 AI 提供的证据/原话摘录) - ⚠️ 印象模糊 - ❌ 记忆不足(此类条目跳过,不进入确认流程) -3. 读取当前用户的画像文件(`profiles/[姓名].md`),对比每条 AI 信息与现有画像数据: - - **画像为空白**:该信息为新增内容,需用户确认 - - **画像已有数据**:需标注冲突,提请用户裁决 - - **内容一致**:可直接确认,无需特别说明 - ---- - -## 步骤二:两阶段确认 - -将条目分为两类处理:**有据可查且无冲突**的条目整体确认一次;**印象模糊、与画像冲突、或属于当前需求(C 模块)**的条目逐条确认。 - ---- - -### 阶段一:有据可查条目整体确认 - -将所有 **✅ 有据可查** 且 **与画像无冲突** 的条目(不含 C 模块),整理成一张摘要表,**一次性展示**: - -``` -📋 有据可查信息汇总(建议整体写入) - -以下信息均有 AI 提供的对话依据,且与现有画像无冲突。请整体确认是否写入: - -| 编号 | 字段 | AI 回答摘要 | 依据/证据 | -|:---|:---|:---|:---| -| 1 | [字段名] | [1-2句摘要] | [AI 标注的来源,如「你在某次聊 X 时提到……」] | -| 2 | [字段名] | [1-2句摘要] | [依据] | -| ... | ... | ... | ... | - -→ 是否将以上全部写入画像? - A. 是,全部写入 - B. 否,请跳过第 [编号] 条 - C. 否,第 [编号] 条我需要修改(请说明修改内容) -``` - -用户确认后,这些条目进入「将要写入」清单,无需再逐条询问。 +4. 读取当前用户的画像,对比每条 AI 信息与现有数据: + - **画像为空白**:该信息为新增内容 + - **画像已有数据**:需标注冲突 + - **内容一致**:可直接采纳 --- -### 阶段二:逐条确认(仅针对以下类型) +## 步骤二:分类处理(不展示有据可查条目) -仅对以下条目逐条向用户确认: +**有据可查且无冲突**的条目:内部整合后**直接写入**,**不向用户展示**。 +**仅对以下类型**向用户发起确认(优先选择题,必要时填空或问答并给回答样例): - **⚠️ 印象模糊** 的条目 - **与画像已有数据存在冲突** 的条目 - **C 模块(当前需求)** 的所有条目(无论可信度,因属高度个人化信息) -**逐条展示格式**: +--- -``` -📋 [模块名称] — 需逐条确认 - -[条目编号]. [字段名称] - AI 的回答:「[原文摘录]」(可信度:⚠️ / 与画像冲突 / C模块) - 画像现状:[当前画像中该字段的值,若为空则写「尚未填写」] - [若为冲突:你之前填写的:「[画像中的原始数据]」] - - → 是否将此信息写入画像? - A. 是,照原文写入 - B. 是,但我想修改一下(请告诉我修改内容) - C. 否,跳过这条 -``` +## 步骤三:逐条确认(选择题优先,必要时填空/问答+样例) -**冲突情况的特殊处理**: +对需要确认的条目,**优先使用选择题**;若选择题无法覆盖,再用填空或开放问答,并**给出回答样例**。 -若 AI 回复与画像中已有的**用户自述数据**存在冲突,必须特别标注: +### 选择题格式(优先) ``` -⚠️ 注意:此条信息与你之前填写的画像数据存在出入! - - AI 的回答:「[原文]」 - 你之前填写的:「[画像中的原始数据]」 - - 请问你希望如何处理? - A. 保留我之前填写的内容(AI 记忆有误) - B. 以 AI 的回答为准(AI 说得更准确) - C. 两者都有参考价值,帮我合并(请说明如何合并) - D. 暂时跳过,我需要想一想 +[字段名称] +AI 的回答:「[原文摘录]」(可信度:⚠️ 印象模糊 / 与画像冲突 / C模块) +[若为冲突:你之前填写的:「[画像中的原始数据]」] + +→ 请选择: + A. [选项一,如:基本符合,可以写入] + B. [选项二,如:不太准确,我来补充] + C. [选项三,如:跳过这条] ``` -**模糊或不完整信息的处理**: - -- **内容模糊**(如「你好像在做天文相关研究」): - ``` - AI 的回答比较模糊,我想在写入画像前先确认一下: - 「[AI 原文]」——你觉得这个描述准确吗?能补充得更具体一些吗? - ``` +### 冲突情况的特殊处理(选择题) -- **标注为 ⚠️ 印象模糊**: - ``` - AI 对这条信息不太确定(标注为印象模糊): - 「[AI 原文]」 - 你觉得这个描述符合实际情况吗? - A. 基本符合,可以写入 - B. 不太准确,我来补充正确信息 - C. 跳过这条 - ``` +``` +⚠️ 此条信息与你之前填写的数据存在出入 -- **C 模块(当前需求)** 的条目,额外提示: - ``` - 💡 「当前需求」是画像中最个人化的维度,AI 的推断仅供参考。 - 请你确认:这条描述是否真实反映了你现在的状态? - ``` +AI 的回答:「[原文]」 +你之前填写的:「[画像中的原始数据]」 ---- +→ 请问你希望如何处理? + A. 保留我之前填写的内容(AI 记忆有误) + B. 以 AI 的回答为准(AI 说得更准确) + C. 两者都有参考价值,帮我合并(请说明如何合并,例:取 AI 的机构名 + 我之前的领域描述) + D. 暂时跳过,我需要想一想 +``` -## 步骤三:汇总确认清单 +### 填空或开放问答(必要时,须附回答样例) -阶段一、阶段二确认完毕后,展示一个汇总清单供用户最终确认: +当选择题无法充分表达时,使用填空或问答,并**给出回答样例**: ``` -📝 本次 AI 记忆导入汇总 +[字段名称] +AI 的回答比较模糊:「[AI 原文]」 -以下信息将写入你的画像,请最终确认: +→ 请补充或修正(若无需修改可回复「保持原样」): + 样例:我目前在 XX 大学读博,导师做计算神经科学方向。 +``` -✅ 将要写入: - - [字段名]:[内容摘要] - - [字段名]:[内容摘要] - ... +``` +💡 「当前需求」是画像中最个人化的维度,AI 的推断仅供参考。 -⏭️ 已跳过: - - [字段名](原因:用户选择跳过 / 与画像冲突保留原值 / 内容不足) - ... +[字段名称] AI 的回答:「[原文]」 -确认无误后请回复「确认写入」,如需修改请告诉我哪条需要调整。 +→ 这条描述是否真实反映了你现在的状态? + A. 是,可以写入 + B. 否,我来补充正确信息(例:我最近主要精力在写毕业论文,卡在实验数据整理) + C. 跳过这条 ``` --- -## 步骤四:写入画像文件 +## 步骤四:写入画像 + +用户完成所有需确认条目的选择/补充后,执行写入: -用户确认后,执行写入: +1. **有据可查且无冲突**的条目:已内部整合,直接写入 +2. **用户确认通过**的模糊/冲突/C 模块条目:写入 +3. **在调用 write_profile 之前**:若尚未确定用户姓名或标识,**此时单独询问**:「请提供您的姓名或标识,用于命名/保存科研数字分身」(用于画像标题及 Cursor 模式下的文件路径)。用户提供后再执行写入。 +4. 使用 `write_profile` 工具更新会话中的画像(Web 模式)或 `StrReplace` 更新 `profiles/[姓名].md`(Cursor 模式) +5. 所有来自 AI 记忆的数据,在字段旁标注 `(来源:AI记忆,已用户确认)` +6. 若某字段原为用户自述且被 AI 记忆覆盖,在字段注释中保留旧值 +7. 更新元信息与审核记录 -1. 使用 `StrReplace` 工具逐字段更新 `profiles/[姓名].md` -2. 所有来自 AI 记忆的数据,在字段旁标注 `(来源:AI记忆,已用户确认)` -3. 若某字段原为用户自述且被 AI 记忆覆盖,在字段注释中保留旧值: - ```html - - ``` -4. 更新元信息: - - `最后更新` 改为今日日期 - - `数据来源` 若原为单一来源,改为 `混合` -5. 在 `## 八、审核记录` 追加一条: - ``` - | [今日日期] | AI记忆导入([模块列表]) | 用户确认后写入(含整体确认 + 逐条确认) | 已写入 | - ``` +**不向用户展示**本次导入的汇总清单。完整画像仅在用户说「查看画像」「审核」时一次性展示。 --- @@ -174,7 +120,7 @@ description: 解析用户从 ChatGPT 等 AI 获取的记忆回复,与现有画 - 若**基础信息仍有空白字段**: ``` - AI 记忆已成功导入!不过画像中还有一些字段未能从 AI 记忆中获取, + AI 记忆已成功导入!不过科研数字分身中还有一些字段未能从 AI 记忆中获取, 建议继续通过对话补充完整。是否现在继续? ``` @@ -189,6 +135,6 @@ description: 解析用户从 ChatGPT 等 AI 获取的记忆回复,与现有画 - 若**画像已相当完整**: ``` - AI 记忆已成功导入!画像目前相当完整。 - 建议进行一次完整审核,查看整体画像是否准确。需要我现在展示完整画像吗? + AI 记忆已成功导入!科研数字分身目前相当完整。 + 建议进行一次完整审核。可以说「查看画像」或「审核」查看完整结果并确认。 ``` diff --git a/libs/profile_helper/skills/update-profile/SKILL.md b/libs/profile_helper/skills/update-profile/SKILL.md index 3a38dc9..b39834e 100644 --- a/libs/profile_helper/skills/update-profile/SKILL.md +++ b/libs/profile_helper/skills/update-profile/SKILL.md @@ -1,6 +1,6 @@ --- name: update-profile -description: 对已有科研数字分身进行精确的字段补充或修改。当用户说「修改」「更新」「补充」「不对」等,或审核反馈后需要更新数据时使用。 +description: 对已有科研人员画像进行精确的字段补充或修改。当用户说「修改」「更新」「补充」「不对」等,或审核反馈后需要更新数据时使用。 --- # 画像更新与修改 diff --git a/tests/test_profile_helper_api.py b/tests/test_profile_helper_api.py index 2dbfd65..adeb46f 100644 --- a/tests/test_profile_helper_api.py +++ b/tests/test_profile_helper_api.py @@ -71,9 +71,9 @@ def test_profile_helper_chat_stream_updates_profile(client: TestClient, monkeypa from app.services.profile_helper import sessions as profile_sessions def _fake_run_agent(user_message: str, session: dict, *, stream: bool = False, model: str | None = None): - # Simulate tool side-effects performed by the real agent - session["profile"] = f"profile updated: {user_message}" - session["forum_profile"] = "# Test Forum Profile\n\nGenerated forum profile" + # Simulate the real tool side-effects, including auto-save. + profile_sessions.save_profile(session, f"# 科研人员画像 — Test User\n\nprofile updated: {user_message}") + profile_sessions.save_forum_profile(session, "# Test Forum Profile\n\nGenerated forum profile") text = "OK" if stream: for ch in text: @@ -99,7 +99,7 @@ def _fake_run_agent(user_message: str, session: dict, *, stream: bool = False, m profile_resp = client.get(f"/profile-helper/profile/{session_id}") assert profile_resp.status_code == 200 profile = profile_resp.json() - assert profile["profile"] == "profile updated: hello" + assert "profile updated: hello" in profile["profile"] assert profile["forum_profile"].startswith("# Test Forum Profile") forum_download = client.get(f"/profile-helper/download/{session_id}/forum") @@ -107,6 +107,30 @@ def _fake_run_agent(user_message: str, session: dict, *, stream: bool = False, m assert "attachment; filename=\"forum-profile.md\"" in forum_download.headers.get("content-disposition", "") +def test_profile_helper_auto_saves_profiles_to_workspace(isolated_workspace: Path): + from app.services.profile_helper import sessions as profile_sessions + + profile_sessions._sessions.clear() + session_id, session = profile_sessions.get_or_create("persisted-session") + + profile_path = profile_sessions.save_profile( + session, + "# 科研人员画像 — Test User\n\nAuto-saved profile", + ) + forum_path = profile_sessions.save_forum_profile( + session, + "# Test Forum Profile\n\nAuto-saved forum profile", + ) + + expected_dir = isolated_workspace / "profile_helper" / "profiles" + assert profile_path.parent == expected_dir + assert forum_path.parent == expected_dir + assert profile_path.exists() + assert forum_path.exists() + assert "Auto-saved profile" in profile_path.read_text(encoding="utf-8") + assert "Auto-saved forum profile" in forum_path.read_text(encoding="utf-8") + + def test_profile_helper_chat_rejects_empty_message(client: TestClient): resp = client.post("/profile-helper/chat", json={"message": " "}) assert resp.status_code == 400