""" Notes app state accessor. """ from __future__ import annotations import datetime import re from typing import Any from bench_env.task.base import BaseApp from bench_env.task.tencent_meeting.app import TencentMeeting from bench_env.task.utils import extract_numbers, integer_labels, norm, subsequence_contains_numbers NOTES_CREATE_TITLES = ["下周计划", "读书笔记", "备忘"] NOTES_NEW_TODO_TEXTS = ["买菜", "交水电费", "预约体检", "回复邮件"] NOTES_NEW_FOLDER_NAMES = ["工作", "学习", "生活", "旅行"] NOTES_MOVE_FOLDER_NAMES = ["重要", "归档", "项目"] NOTES_PRIVATE_CREATE_TITLES = ["密码备忘", "私人日记", "账号记录"] NOTES_PRIVATE_CREATE_CONTENTS = ["这是私密内容", "仅自己可见"] NOTES_REMINDER_CREATE_TITLES = ["明天开会", "周末聚餐", "还书"] NOTES_REMINDER_CREATE_CONTENTS = ["记得带文件", "订餐厅", "去图书馆"] NOTES_CREATE_CHANGES = ["notes.notes"] def _is_trashed(note: dict[str, Any]) -> bool: value = note.get("trashedAt") return isinstance(value, (int, float)) and value > 0 def _is_private(note: dict[str, Any]) -> bool: return bool(note.get("isPrivate")) _BANK_MARKER_PARENS_TAIL = re.compile(r"(\s*\d{3,6}\s*)") def _note_content_matches_marker_group(content: str, marker: str) -> bool: """备忘录里银行名写法较随意:允许不写尾号、不写「中国」前缀等,仍视为提到该卡。""" m = str(marker).strip() c = str(content) if not m or not c: return False if m in c: return True core = _BANK_MARKER_PARENS_TAIL.sub("", m).strip() if core and core in c: return True if core.startswith("中国") and len(core) > 2: without_prefix = core[2:].strip() if without_prefix and without_prefix in c: return True return False def _note_text(note: dict[str, Any]) -> str: title = str(note.get("title", "") or "").strip() content = str(note.get("content", "") or "").strip() return f"{title}\n{content}".strip() def _pick_keywords_from_note(note: dict[str, Any]) -> list[str]: lines = [ line.strip() for line in str(note.get("content", "") or "").splitlines() if line.strip() ] keywords = lines[:2] title = str(note.get("title", "") or "").strip() if len(keywords) < 2 and title: keywords.append(title[:2] if len(title) >= 2 else title) if len(keywords) < 2: keywords.append("便签") return keywords[:2] class Notes(BaseApp): """ Notes state accessor. Usage: notes = Notes(input.apps["notes"]) notes.latest_note notes.check_latest_contains("天气", "北京") """ @staticmethod def _sample_visible_note(env_state: dict[str, Any], rng: Any) -> dict[str, str]: notes = Notes(env_state["apps"]["notes"]) candidates = notes.visible_notes if not candidates: raise ValueError("No visible note found in notes state") chosen = rng.choice(candidates) return {"note_title": str(chosen.get("title") or "")} @staticmethod def _sample_note_with_content_target( env_state: dict[str, Any], rng: Any ) -> dict[str, str]: notes = Notes(env_state["apps"]["notes"]) candidates = [ note for note in notes.visible_notes if str(note.get("content", "") or "").strip() ] if not candidates: raise ValueError("No visible note with content found") chosen = rng.choice(candidates) keyword1, keyword2 = _pick_keywords_from_note(chosen) return { "note_title": str(chosen.get("title") or ""), "keyword1": keyword1, "keyword2": keyword2, } @staticmethod def _sample_search_target(env_state: dict[str, Any], rng: Any) -> dict[str, str]: notes = Notes(env_state["apps"]["notes"]) candidates = notes.visible_notes if not candidates: raise ValueError("No visible note found for search target") chosen = rng.choice(candidates) title = str(chosen.get("title") or "").strip() keyword = title[:2] if len(title) >= 2 else _pick_keywords_from_note(chosen)[0] return {"keyword": keyword, "note_title": title} @staticmethod def _sample_incomplete_todo(env_state: dict[str, Any], rng: Any) -> dict[str, str]: notes = Notes(env_state["apps"]["notes"]) candidates = notes.incomplete_todos if not candidates: raise ValueError("No incomplete todo found in notes state") chosen = rng.choice(candidates) return {"todo_text": str(chosen.get("text") or "")} @staticmethod def reminder_time_labels(timestamp: int | float) -> list[str]: dt = datetime.datetime.fromtimestamp(float(timestamp) / 1000.0) month = dt.month day = dt.day hour = dt.hour minute = dt.minute hh = f"{hour:02d}" mm = f"{minute:02d}" return [ f"{month}月{day}日 {hour}:{mm}", f"{month}月{day}日 {hh}:{mm}", f"{hour}:{mm}", f"{hh}:{mm}", ] @property def notes(self) -> list[dict[str, Any]]: return self.get_list("notes") @property def todos(self) -> list[dict[str, Any]]: return self.get_list("todos") @property def folders(self) -> list[dict[str, Any]]: return self.get_list("folders") @property def settings(self) -> dict[str, Any]: return self.get("settings", {}) @property def visible_notes(self) -> list[dict[str, Any]]: return [ note for note in self.notes if not _is_trashed(note) and not _is_private(note) ] @property def private_notes(self) -> list[dict[str, Any]]: return [ note for note in self.notes if not _is_trashed(note) and _is_private(note) ] @property def trashed_notes(self) -> list[dict[str, Any]]: return [note for note in self.notes if _is_trashed(note)] @property def incomplete_todos(self) -> list[dict[str, Any]]: return [todo for todo in self.todos if not bool(todo.get("completed"))] @property def completed_todos(self) -> list[dict[str, Any]]: return [todo for todo in self.todos if bool(todo.get("completed"))] @property def latest_note(self) -> dict[str, Any] | None: if not self.notes: return None return max(self.notes, key=lambda note: int(note.get("updatedAt", 0) or 0)) @property def latest_note_text(self) -> str: note = self.latest_note return _note_text(note) if note else "" @property def longest_note(self) -> dict[str, Any] | None: if not self.notes: return None return max(self.notes, key=lambda note: len(str(note.get("content", "") or ""))) def latest_n_notes(self, n: int) -> list[dict[str, Any]]: if n <= 0: return [] sorted_notes = sorted( self.notes, key=lambda note: int(note.get("updatedAt", 0) or 0), reverse=True ) return sorted_notes[:n] def find_note_containing(self, text: str) -> dict[str, Any] | None: target = str(text or "").strip().lower() if not target: return None for note in self.notes: if target in _note_text(note).lower(): return note return None def find_note_by_title(self, title: str) -> dict[str, Any] | None: target = str(title or "").strip() if not target: return None for note in self.notes: if str(note.get("title") or "").strip() == target: return note for note in self.notes: if target in str(note.get("title") or ""): return note return None def latest_note_by_title(self, title: str) -> dict[str, Any] | None: target = str(title or "").strip() if not target: return None matched = [ note for note in self.notes if str((note or {}).get("title", "")).strip() == target ] if not matched: return None return max(matched, key=lambda note: int((note or {}).get("updatedAt", 0) or 0)) def note_text_by_title(self, title: str) -> str: note = self.latest_note_by_title(title) return _note_text(note) if note else "" def find_note_with_keywords(self, keywords: list[str]) -> dict[str, Any] | None: if not keywords: return None lowered = [str(keyword or "").lower() for keyword in keywords if str(keyword or "").strip()] if not lowered: return None for note in self.notes: full = _note_text(note).lower() if all(keyword in full for keyword in lowered): return note return None def find_todo_by_text(self, text: str) -> dict[str, Any] | None: target = str(text or "").strip() if not target: return None for todo in self.todos: if str(todo.get("text") or "").strip() == target: return todo for todo in self.todos: if target in str(todo.get("text") or ""): return todo return None def find_folder_by_name(self, name: str) -> dict[str, Any] | None: target = str(name or "").strip() if not target: return None for folder in self.folders: if str(folder.get("name") or "").strip() == target: return folder for folder in self.folders: if target in str(folder.get("name") or ""): return folder return None # ---- 对比层(需要 init)---- def new_todos(self) -> list[dict[str, Any]]: """init 后新增的 todo(按 ID 差集)""" init_ids = {str(t.get("id") or "") for t in self.init.todos} return [t for t in self.todos if str(t.get("id") or "") not in init_ids] def find_new_todo_by_text(self, text: str) -> dict[str, Any] | None: """在新增的 todo 中按文本查找""" target = str(text or "").strip() if not target: return None for todo in self.new_todos(): if str(todo.get("text") or "").strip() == target: return todo return None def removed_todo_ids(self) -> set[str]: """init 中存在但 current 中已不存在的 todo id 集合(按 ID 差集,避免文本清空造成的误判)""" init_ids = {str(t.get("id") or "") for t in self.init.todos} curr_ids = {str(t.get("id") or "") for t in self.todos} return init_ids - curr_ids def check_todo_deleted( self, text: str, *, field: str = "todos.todo_deleted" ) -> dict[str, Any]: """检查 init 中文本为 text 的 todo 是否已按 ID 从 current 中消失""" init_todo = self.init.find_todo_by_text(text) init_id = str((init_todo or {}).get("id") or "") removed = bool(init_id) and init_id in self.removed_todo_ids() return { "field": field, "expected": text, "actual": None if removed else text, "passed": removed, } def check_other_init_todos_preserved( self, *, exclude_text: str | None = None, field: str = "todos.other_todos_preserved", ) -> dict[str, Any]: """检查 init 中除 exclude_text 之外的 todo 全部仍在 current(防止误删其他项)""" exclude_id = "" if exclude_text: init_target = self.init.find_todo_by_text(exclude_text) if init_target is not None: exclude_id = str(init_target.get("id") or "") curr_ids = {str(t.get("id") or "") for t in self.todos} missing_texts = [ str(t.get("text") or "") for t in self.init.todos if str(t.get("id") or "") != exclude_id and str(t.get("id") or "") not in curr_ids ] return { "field": field, "expected": [], "actual": missing_texts, "passed": not missing_texts, } def check_all_completed_todos_deleted( self, *, field: str = "todos.completed_all_deleted" ) -> dict[str, Any]: """检查 init 中所有 completed todo 均已按 ID 从 current 中消失(defaults 保证至少有 1 条)""" init_completed_ids = {str(t.get("id") or "") for t in self.init.completed_todos} curr_ids = {str(t.get("id") or "") for t in self.todos} remaining = len(init_completed_ids & curr_ids) return { "field": field, "expected": 0, "actual": remaining, "passed": bool(init_completed_ids) and remaining == 0, } def check_incomplete_todos_preserved( self, *, field: str = "todos.incomplete_preserved" ) -> dict[str, Any]: """检查 init 中所有 incomplete todo 仍在 current(防止误删未完成项)""" curr_ids = {str(t.get("id") or "") for t in self.todos} missing_texts = [ str(t.get("text") or "") for t in self.init.incomplete_todos if str(t.get("id") or "") not in curr_ids ] return { "field": field, "expected": [], "actual": missing_texts, "passed": not missing_texts, } def check_note_with_title_contains( self, title: str, *keywords: str, field: str | None = None ) -> dict[str, Any]: if field is None: field = f"note_{title}" note = self.find_note_by_title(title) init_note = self.init.find_note_by_title(title) if self.has_init else None content = _note_text(note) if note else "" init_content = _note_text(init_note) if init_note else "" missing = [keyword for keyword in keywords if keyword not in content] changed = note is not None and content != init_content return { "field": field, "expected": {"title": title, "contains": list(keywords)}, "actual": { "title": note.get("title") if note else None, "content": content[:200] if content else "", "changed": changed, "missing": missing, }, "passed": note is not None and changed and not missing, } def new_notes(self) -> list[dict[str, Any]]: """init 后新增的笔记(按 ID 差集)。""" init_ids = {str(note.get("id") or "") for note in self.init.notes} return [ note for note in self.notes if str(note.get("id") or "") not in init_ids ] def check_no_new_notes( self, *, field: str = "no_new_notes" ) -> dict[str, Any]: """确认本次任务没有新增任何笔记。""" new = self.new_notes() preview = [ { "id": note.get("id"), "title": str(note.get("title") or ""), } for note in new[:5] ] return { "field": field, "expected": "不新增任何笔记", "actual": {"addedCount": len(new), "addedPreview": preview}, "passed": len(new) == 0, } def check_note_title_exists( self, title: str, *, field: str = "notes.note.title" ) -> dict[str, Any]: note = self.latest_note_by_title(title) return { "field": field, "expected": str(title), "actual": note.get("title") if note else None, "passed": note is not None, } def check_latest_contains( self, *keywords: str, field: str = "latest_note" ) -> dict[str, Any]: """检查最新笔记是否包含所有关键词。""" note = self.latest_note if not note: return { "field": field, "expected": f"contains {list(keywords)}", "actual": "no notes", "passed": False, } content = _note_text(note) missing = [keyword for keyword in keywords if keyword not in content] return { "field": field, "expected": f"contains {list(keywords)}", "actual": content[:120] + (f" (missing: {missing})" if missing else ""), "passed": not missing, } def check_latest_contains_meeting_id_and_password( self, meeting_id: str, password: str, *, field: str = "latest_note_meeting", ) -> dict[str, Any]: """检查最新笔记包含会议号(忽略数字间空格)和密码。""" note = self.latest_note content = _note_text(note) if note else "" mid = re.sub(r"\s+", "", str(meeting_id)) if not mid: passed = False else: passed = ( bool(content) and bool(TencentMeeting.meeting_id_pattern(mid).search(content)) and str(password) in content ) return { "field": field, "expected": f"会议号 {mid} + 密码 {password}", "actual": content[:200] or "(none)", "passed": passed, } def check_latest_contains_any_of( self, keywords: list[str] | tuple[str, ...], *, field: str = "latest_note_any_of", ) -> dict[str, Any]: note = self.latest_note if not note: return { "field": field, "expected": {"any_of": list(keywords)}, "actual": "no notes", "passed": False, } content = _note_text(note) matched = next((keyword for keyword in keywords if keyword and keyword in content), None) return { "field": field, "expected": {"any_of": list(keywords)}, "actual": content[:120], "passed": matched is not None, } def check_latest_norm_contains( self, *keywords: str, field: str = "latest_note_norm", ) -> dict[str, Any]: """按 norm 且去空格后检查最新笔记,兼容中文标题里的空格/符号差异。""" note = self.latest_note if not note: return { "field": field, "expected": f"norm contains {list(keywords)}", "actual": "no notes", "passed": False, } content = norm(_note_text(note)).replace(" ", "") missing = [ keyword for keyword in keywords if norm(keyword).replace(" ", "") not in content ] return { "field": field, "expected": f"norm contains {list(keywords)}", "actual": content[:120] + (f" (missing: {missing})" if missing else ""), "passed": not missing, } def check_latest_contains_number( self, expected: float, *, tolerance: float = 0.01, field: str = "latest_note_number", ) -> dict[str, Any]: content = self.latest_note_text if not content: return { "field": field, "expected": expected, "actual": "no notes", "passed": False, } numbers = extract_numbers(content) exp = float(expected) passed = any( abs(num - exp) <= tolerance or abs(abs(num) - abs(exp)) <= tolerance for num in numbers ) return { "field": field, "expected": {"number": expected, "tolerance": tolerance}, "actual": numbers[:20], "passed": passed, } def check_latest_contains_all_numbers( self, expected_numbers: list[float], *, tolerance: float = 0.01, field: str = "latest_note_numbers", ) -> dict[str, Any]: content = self.latest_note_text if not content: return { "field": field, "expected": expected_numbers, "actual": "no notes", "passed": False, } numbers = extract_numbers(content) missing = [ exp for exp in expected_numbers if not any( abs(num - float(exp)) <= tolerance or abs(abs(num) - abs(float(exp))) <= tolerance for num in numbers ) ] return { "field": field, "expected": {"numbers": expected_numbers, "tolerance": tolerance}, "actual": {"numbers": numbers[:20], "missing": missing}, "passed": not missing, } def check_latest_contains_number_lines( self, expected_numbers: list[float], *, tolerance: float = 0.01, field: str = "latest_note_number_lines", ) -> dict[str, Any]: """检查最新笔记的正文按分行顺序包含指定数字。""" note = self.latest_note content = str((note or {}).get("content") or "") lines = [line.strip() for line in content.splitlines() if line.strip()] matched: list[dict[str, Any]] = [] line_idx = 0 for expected in expected_numbers: found = None while line_idx < len(lines): line = lines[line_idx] numbers = extract_numbers(lines[line_idx]) number_ok = any(abs(num - float(expected)) <= tolerance for num in numbers) if float(expected).is_integer(): number_ok = number_ok or any( label in line for label in integer_labels(int(expected)) ) if number_ok: found = {"line": line, "number": float(expected)} line_idx += 1 break line_idx += 1 if found is None: break matched.append(found) return { "field": field, "expected": {"line_numbers": expected_numbers, "tolerance": tolerance}, "actual": {"lines": lines[:20], "matched": matched}, "passed": len(matched) == len(expected_numbers), } def check_note_with_title_has_lines( self, title: str, expected_lines: list[str], *, field: str | None = None ) -> dict[str, Any]: if field is None: field = f"note_{title}_lines" note = self.latest_note_by_title(title) actual_lines = ( [line.strip() for line in str(note.get("content") or "").splitlines() if line.strip()] if note else [] ) missing = [ expected for expected in expected_lines if not any(expected in line for line in actual_lines) ] return { "field": field, "expected": {"title": title, "lines": expected_lines}, "actual": {"title": note.get("title") if note else None, "lines": actual_lines[:20]}, "passed": note is not None and not missing, } def check_note_with_title_has_number_sequence( self, title: str, expected_numbers: list[float], *, tolerance: float = 0.02, field: str = "notes.sequence", ) -> dict[str, Any]: content = self.note_text_by_title(title) numbers = extract_numbers(content) return { "field": field, "expected": expected_numbers, "actual": numbers[:30], "passed": bool(content) and subsequence_contains_numbers(numbers, expected_numbers, tol=tolerance), } def check_note_with_title_mentions_groups( self, title: str, marker_groups: list[list[str]], *, field: str = "notes.markers", ) -> dict[str, Any]: content = self.note_text_by_title(title) missing_groups = [ group for group in marker_groups if not any( str(marker).strip() and _note_content_matches_marker_group(content, str(marker)) for marker in group ) ] return { "field": field, "expected": {"title": title, "marker_groups": marker_groups}, "actual": content[:200] or "(none)", "passed": bool(content) and not missing_groups, }