Skill: qiushibang/feishu-room-booking (version 3.1.0)

--- name: feishu-room-booking description: | 字节内部使用的飞书会议室查询与预订技能（覆盖全球130+工区、7300+会议室）。当用户提到"查会议室"、"订会议室"、"空闲会议室"、"预订会议室"、"开会"、"找个会议室"、"F4会议室"、"紫金会议室"、"哪个会议室有空"、或者创建会议时需要自动匹配空闲会议室时，必须使用此 skill。也适用于用户要求创建日程并指定楼栋/区域时自动完成会议室预订的场景。也适用于用户提到"会议室偏好"、"我的偏好"、"候补"、"补订会议室"、"自动订会议室"时。当用户未指定楼栋时，优先读取用户偏好中的默认楼栋；无偏好则主动询问城市/楼栋。 --- # 飞书会议室查询与预订 管理飞书会议室的忙闲查询、自动匹配、日程预订、偏好管理和候补轮询。 ## 前置依赖 - `lark-cli` 已安装且 bot 身份可用 - 飞书应用已开通相关权限（calendar:calendar.free_busy:read, calendar:calendar.event:create 等） - 数据文件：`references/room-mapping.json`（全球 130+ 工区、7300+ 会议室）、`references/user-preferences.json`、`references/room-waitlist.json`、`references/weekly-workspace.json` - 脚本目录：`scripts/` - 建筑匹配使用索引查找（O(1)），忙闲查询自动 10 线程并行 ## 工具脚本 所有会议室操作**必须通过脚本**，不要手写 bash 循环。 ### query_rooms.py — 会议室查询 ```bash # 列出所有楼栋 python3 scripts/query_rooms.py --list-buildings # 列出指定楼栋的会议室 python3 scripts/query_rooms.py --list-rooms -b "丽金" # 查询空闲会议室（表格输出，自动并行查询） python3 scripts/query_rooms.py -b "丽金" \ -s "2026-04-20T14:00:00+08:00" \ -e "2026-04-20T15:00:00+08:00" -o table # 按容量筛选 python3 scripts/query_rooms.py -b "丽金" \ -s "2026-04-20T14:00:00+08:00" \ -e "2026-04-20T15:00:00+08:00" \ --capacity-gte 8 -o table # 调整并行度（默认 10 线程） python3 scripts/query_rooms.py -b "丽金" \ -s "2026-04-20T14:00:00+08:00" \ -e "2026-04-20T15:00:00+08:00" \ --max-workers 20 -o table ``` ### manage_preferences.py — 偏好管理 ```bash # 设置偏好 python3 scripts/manage_preferences.py --set \ --user "ou_xxx" --building "丽金" --capacity-gte 8 \ --preferred-rooms "F11-07,F11-15" --note "偏好靠近电梯" # 读取偏好 python3 scripts/manage_preferences.py --get --user "ou_xxx" # 记录用户选择（自动学习） python3 scripts/manage_preferences.py --learn \ --user "ou_xxx" --room "F11-15(8)" --building "丽金智地中心 B座" # 列出所有偏好 python3 scripts/manage_preferences.py --list ``` ### watch_waitlist.py — 候补管理 ```bash # 查看候补状态 python3 scripts/watch_waitlist.py --status # 执行一轮轮询 python3 scripts/watch_waitlist.py --poll # 添加候补 python3 scripts/watch_waitlist.py --add \ --event-id "xxx" --summary "周会" \ --start "2026-04-20T14:00:00+08:00" --end "2026-04-20T15:00:00+08:00" \ --building "丽金" --capacity-gte 8 # 移除候补 python3 scripts/watch_waitlist.py --remove --event-id "xxx" # 清理过期候补 python3 scripts/watch_waitlist.py --clean ``` ### workspace_manager.py — 工区时间线管理 ```bash # 查看当前工区和下周工区 python3 scripts/workspace_manager.py --get # 设置当前工区（默认从今天到本周日） python3 scripts/workspace_manager.py --set --workspace "丽金智地中心 B座" # 设置指定日期范围的工区 python3 scripts/workspace_manager.py --set --workspace "紫金数码园4号楼" \ --from "2026-04-30" --to "2026-05-02" # 设置下周工区 python3 scripts/workspace_manager.py --set-next --workspace "紫金数码园4号楼" # 推荐下周工区 / 周五提醒检查 / 查看时间线 python3 scripts/workspace_manager.py --recommend python3 scripts/workspace_manager.py --check-friday-reminder python3 scripts/workspace_manager.py --timeline ``` ## 数据文件 | 文件 | 用途 | |------|------| | `references/room-mapping.json` | 会议室资源 ID 映射 | | `references/user-preferences.json` | 用户个人偏好 | | `references/room-waitlist.json` | 候补预订队列 | | `references/weekly-workspace.json` | 当前工区 / 下周工区时间线 | ## 核心流程 ### 流程 A：查询空闲会议室 用户只想看哪些会议室有空。 1. **解析意图** — 时间段、楼栋、容量需求 2. **确定楼栋** — - 用户明确指定 → 直接使用 - 用户未指定 → 先读取用户偏好获取默认楼栋（`manage_preferences.py --get`） - 用户无偏好 → 读取当前工区 / 下周工区（`workspace_manager.py --get`）作为默认楼栋 - 仍然无法确定 → ⚠️ 必须询问城市/楼栋，不要猜测。可提示"北京/上海/深圳/杭州/..."等热门城市 3. **确定日期** — ⚠️ 严格验证星期几 4. **执行查询** — `python3 scripts/query_rooms.py -u "ou_xxx" -s ... -e ... -o table` - 用户已指定楼栋时仍优先传 `-b` - 未指定楼栋时，脚本会按“用户偏好 → 当前/下周工区”兜底 5. **呈现结果** — 直接转发脚本输出 ### 流程 B：创建会议并自动预订 用户要开会，需要创建日程 + 匹配会议室。 1. **解析意图** — 标题、时间、楼栋、容量、参会人 2. **确定日期** — ⚠️ 严格验证星期几 3. **确定默认楼栋** — - 优先读取用户偏好：`python3 scripts/manage_preferences.py --get --user "ou_xxx"` - 无偏好时读取工区时间线：`python3 scripts/workspace_manager.py --get` - 用户显式指定楼栋时覆盖默认值 4. **查询空闲会议室** — 用脚本查询，带上容量筛选 5. **用户选择** — `feishu_ask_user_question` 弹卡片 6. **创建日程** — `feishu_calendar_event` create 7. **添加会议室+参会人** — `feishu_calendar_event_attendee` create - ⚠️ 字段名是 `attendee_id`，不是 `id` 8. **Reflection 二次校验** — 等 5 秒后查 attendee list / event detail - `confirmed`：resource 已 accept，才能对用户说“预订成功” - `pending`：resource 已出现但状态未定，只能说“已提交，等待确认” - `failed`：resource decline / 缺失，不能宣告成功 9. **Fallback** — `failed` 时自动换下一个空闲会议室 10. **记录选择** — 仅 `confirmed` 后调用 `python3 scripts/manage_preferences.py --learn --user "ou_xxx" --room "F11-15(8)" --building "..."` ### 流程 C：用户偏好管理 用户设置或修改会议室偏好，后续自动应用。 **设置偏好：** - 用户说"我一般用丽金B座8人以上的会议室" - 调用 `--set` 写入偏好 **自动学习：** - 每次流程 B 完成后，调用 `--learn` 记录选择 - 连续 3 次选同一个会议室 → 自动标记为偏好会议室 - 最近 3 次选同一楼栋 → 自动设为默认楼栋 **应用偏好：** - 流程 B 的 Step 3 自动读取偏好 - 偏好楼栋不匹配时，按偏好查；没空闲时追问是否换楼栋 - 容量需求自动带入查询 ### 流程 D：扫描日程自动补订 自动检测用户日程中缺少会议室的会议并补订。 **触发方式：** - **手动**：用户说"帮我检查一下有没有缺会议室的日程"、"补订会议室" - **自动**：Heartbeat 定时任务 **扫描步骤：** 1. 调用 `feishu_calendar_event` list 获取用户近期日程（未来 24 小时） 2. 首轮检查当前用户是否已 accept：优先读事件级 `self_rsvp_status`，没有时再看 attendees 中当前用户的 `rsvp_status / status / response_status` 3. 对 resource 参会人做 reflection 分类，而不是只看“是否存在 resource” - `confirmed`：已有已确认会议室，直接跳过 - `pending`：会议室状态待确认，先进入二次确认队列，不立即补订 - `failed` / `missing`：视为当前没有成功会议室，可继续补订 4. 对首轮判定需要补订的事件，再做一次二次确认 5. 二次确认后仍为 `confirmed` 缺会议室的日程： - 先读取用户偏好确定默认楼栋和容量 - 无默认楼栋时回退到当前工区 / 下周工区 - 查询空闲会议室 - 有空闲 → 自动预订（跳过用户确认，因为是补订场景） - 全满 → 加入候补队列 **判断是否需要会议室的逻辑：** - ❌ 跳过：当前用户未 accept、已有 `confirmed` 会议室、日期型全天事件 - ⏳ 待确认：当前用户状态缺失，或 resource 已存在但 RSVP 未稳定 - ✅ 补订：当前用户已 accept，且当前没有 `confirmed` 会议室 ### 流程 E：候补轮询 会议室满了时的自动候补机制。 **添加候补：** - 流程 D 发现全满时，调用 `watch_waitlist.py --add` 加入队列 - 候补项应写入已经确定好的楼栋；默认楼栋的决策仍遵循“用户偏好 → 当前/下周工区” **轮询检查：** - Heartbeat 或手动触发 `watch_waitlist.py --poll` - 对每个 waiting 状态的候补，查询当前时段空闲会议室 - 找到空闲 → 标记为 `ready`，通知 agent 执行预订 - agent 提交预订后进入 `verification_pending`，等待 5 秒后二次校验 - 仍然满 → 记录已尝试列表，等待下次轮询 **预订成功后：** - `confirmed` → 从候补移除 - `pending` → 保持 `verification_pending` - `failed / decline` → 回到 `waiting`，允许继续候补或 fallback **清理：** - 定期 `--clean` 清理已过期的候补（开始时间超过 1 小时） ### 流程 F：工区时间线管理 用户需要声明当前工区、设置下周工区，或让系统给出默认工区建议。 **支持动作：** - `python3 scripts/workspace_manager.py --get` 查看当前工区 / 下周工区 - `python3 scripts/workspace_manager.py --set --workspace "丽金智地中心 B座"` 设置当前工区 - `python3 scripts/workspace_manager.py --set-next --workspace "紫金数码园4号楼"` 设置下周工区 - `python3 scripts/workspace_manager.py --recommend` 基于近期会议室选择推荐工区 - `python3 scripts/workspace_manager.py --check-friday-reminder` 检查是否需要周五提醒 - `python3 scripts/workspace_manager.py --timeline` 查看完整时间线 **默认楼栋决策优先级：** 1. 用户本次明确指定楼栋 2. 用户偏好中的 `default_building` 3. 当前工区 / 与查询日期匹配的下周工区 4. 仍然无法确定时追问用户 --- ## 交互规范 ### 自然语言解析 | 用户说 | 解析 | |--------|------| | "明天下午3点开会" | 明天 15:00，默认 1 小时。未指定楼栋时先查偏好，再查工区 | | "找个会议室" | 读取偏好 → 用默认楼栋和容量。无偏好则读当前工区；仍无结果再问城市/楼栋 | | "查一下丽金B座明天下午" | Flow A，匹配 "丽金B座" → 丽金智地中心 B座 | | "帮我找丽金B座11楼的会议室" | Flow A，先匹配楼栋，再用楼层 `11` 缩小房间范围 | | "查一下 F11-15 明天下午是否空闲" | Flow A，优先按房间号匹配楼栋，再按房间号缩小到单个候选 | | "我这周在丽金B座" | 流程 F，设置当前工区时间线 | | "下周回紫金" | 流程 F，设置下周工区 | | "帮我设个偏好，丽金B座8人以上" | 流程 C，设置偏好 | | "查一下我有没有缺会议室的会" | 流程 D，扫描日程 | | "候补状态怎么样了" | 流程 E，查看候补 | | "北京有哪些工区" | `--list-buildings` 筛选含"北京"的行 | ### 楼栋匹配提示 当前覆盖全球 130+ 工区。agent 匹配楼栋时： - 中文关键词（"丽金"、"紫金"、"大钟寺"）→ 自动模糊匹配 - 英文缩写（"F4"、"F11"）→ 自动匹配别名 - 楼层 / 房间号差异表达（"11楼" / "11F" / "F11-15" / "11-15" / "DiscussionBooth A"）→ 自动抽取并缩小候选 - 城市筛选：用户说"北京的会议室" → 先 `--list-buildings` 再 grep "北京" - 匹配到多个结果时 → 列出来让用户选，不要默认猜一个 ### 用户确认原则 - 流程 B（主动创建）：必须确认时间、会议室、参会人 - 流程 D（自动补订）：不需要确认，直接执行 - 流程 E（候补预订）：不需要确认，直接执行 ## 注意事项 1. **并行查询**：freebusy 查询自动 10 线程并行，大工区（200+ 间）可加 `--max-workers 20` 2. **会议室是 resource**：attendee type 为 `"resource"`，`attendee_id` 传 `omm_xxx` 3. **预订异步**：添加后等 5 秒再查 RSVP 4. **时区统一**：`Asia/Shanghai`（+08:00），ISO 8601 5. **日期验证**：涉及相对时间必须验证星期几 6. **脚本优先**：统一用 scripts/ 下的脚本 7. **时间修改风险**：patch 改时间后会议室可能 decline，必须重新验证 8. **偏好自动学习**：每次预订成功后调用 `--learn` 记录 9. **楼栋匹配**：使用索引查找（O(1)），支持模糊匹配 name 和所有 alias 10. **全球覆盖**：room-mapping.json 含 130+ 工区、7300+ 间会议室，未指定楼栋时优先使用用户偏好，再回退到当前/下周工区 11. **工区时间线**：weekly-workspace.json 只负责当前工区 / 下周工区，不替代用户个人偏好和自动学习

path: references/room-waitlist.json
content: {
  "pending": []
}

path: references/user-preferences.json
content: {
  "preferences": {
    "ou_3e543a7e7b42fcc8c1c654bb9c60f45b": {
      "default_building": "丽金智地中心 B座",
      "capacity_gte": 8,
      "capacity_lte": 999,
      "preferred_rooms": [
        "F11-15(8)"
      ],
      "note": "偏好8人以上会议室",
      "selection_history": [
        {
          "room": "F11-15(8)",
          "building": "丽金智地中心 B座",
          "time": "2026-04-17T16:05:40.179862"
        },
        {
          "room": "F11-15(8)",
          "building": "丽金智地中心 B座",
          "time": "2026-04-17T16:05:40.232104"
        },
        {
          "room": "F11-15(8)",
          "building": "丽金智地中心 B座",
          "time": "2026-04-17T16:05:40.278039"
        },
        {
          "room": "F11-15(8)",
          "building": "丽金智地中心 B座",
          "time": "2026-04-17T16:43:21.305775"
        },
        {
          "room": "F11-15(8)",
          "building": "丽金智地中心 B座",
          "time": "2026-04-20T10:39:19.064075"
        }
      ]
    }
  }
}

path: references/weekly-workspace.json
content: {
  "segments": [],
  "next_week": null
}


path: scripts/booking_verifier.py
content: #!/usr/bin/env python3

from __future__ import annotations

from typing import Any

ACCEPTED_STATUSES = {"accept", "accepted"}
DECLINED_STATUSES = {"decline", "declined"}
PENDING_STATUSES = {"needs_action", "tentative", "pending", "pending_accept", "requested"}
ATTENDEE_STATUS_KEYS = (
    "rsvp_status",
    "status",
    "response_status",
    "attendee_status",
    "invitee_status",
)
ATTENDEE_ID_KEYS = ("open_id", "user_id", "attendee_id", "id", "room_id")
STATUS_VALUE_KEYS = ("status", "value", "name")


def normalize_status_value(value: Any) -> str:
    if isinstance(value, str) and value.strip():
        return value.strip().lower()
    if isinstance(value, dict):
        for key in STATUS_VALUE_KEYS:
            nested = value.get(key)
            if isinstance(nested, str) and nested.strip():
                return nested.strip().lower()
    return ""


def get_attendee_identifier(attendee: Any) -> str:
    if not isinstance(attendee, dict):
        return ""
    for key in ATTENDEE_ID_KEYS:
        value = attendee.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()
    return ""


def is_self_attendee(attendee: Any, user_id: str, _organizer_id: str) -> bool:
    if not isinstance(attendee, dict):
        return False
    if attendee.get("is_self") is True:
        return True

    attendee_id = get_attendee_identifier(attendee)
    if user_id and attendee_id == user_id:
        return True
    return False


def get_attendee_status(attendee: Any) -> str:
    if not isinstance(attendee, dict):
        return ""
    for key in ATTENDEE_STATUS_KEYS:
        status = normalize_status_value(attendee.get(key))
        if status:
            return status
    return ""


def get_self_attendee_status(event: dict[str, Any], user_id: str) -> str:
    event_status = normalize_status_value(event.get("self_rsvp_status"))
    if event_status:
        return event_status

    organizer = event.get("event_organizer", {})
    organizer_id = get_attendee_identifier(organizer)
    if user_id and organizer_id == user_id:
        return "accepted"

    attendees = event.get("attendees", [])
    if not isinstance(attendees, list):
        return ""

    for attendee in attendees:
        if not is_self_attendee(attendee, user_id, organizer_id):
            continue
        status = get_attendee_status(attendee)
        if status:
            return status

    return "needs_action"


def get_resource_attendees(event: dict[str, Any]) -> list[dict[str, Any]]:
    attendees = event.get("attendees", [])
    if not isinstance(attendees, list):
        return []
    return [
        attendee for attendee in attendees
        if isinstance(attendee, dict) and normalize_status_value(attendee.get("type")) == "resource"
    ]


def classify_resource_attendees(
    event: dict[str, Any],
    expected_room_id: str | None = None,
) -> dict[str, str]:
    resource_attendees = get_resource_attendees(event)
    if expected_room_id:
        resource_attendees = [
            attendee for attendee in resource_attendees
            if get_attendee_identifier(attendee) == expected_room_id
        ]

    if not resource_attendees:
        return {"status": "missing", "reason": "room_missing"}

    statuses = [get_attendee_status(attendee) for attendee in resource_attendees]
    if any(status in DECLINED_STATUSES for status in statuses):
        return {"status": "failed", "reason": "room_declined"}
    if any(status in ACCEPTED_STATUSES for status in statuses):
        return {"status": "confirmed", "reason": "already_has_confirmed_room"}
    if any(status in PENDING_STATUSES or not status for status in statuses):
        return {"status": "pending", "reason": "room_pending"}
    return {"status": "pending", "reason": "room_pending"}


def classify_event_for_scan(event: dict[str, Any], user_id: str) -> dict[str, str]:
    self_status = get_self_attendee_status(event, user_id)
    if not self_status:
        return {"status": "pending", "reason": "self_status_missing"}
    if self_status not in ACCEPTED_STATUSES:
        return {"status": "failed", "reason": "self_not_accepted"}

    resource_state = classify_resource_attendees(event)
    if resource_state["status"] == "confirmed":
        return {"status": "failed", "reason": "already_has_confirmed_room"}
    if resource_state["status"] == "pending":
        return {"status": "pending", "reason": "room_pending"}
    if resource_state["status"] == "failed":
        return {"status": "confirmed", "reason": "room_declined"}
    return {"status": "confirmed", "reason": "needs_room"}


def classify_post_booking(
    event: dict[str, Any],
    user_id: str,
    expected_room_id: str | None = None,
) -> dict[str, str]:
    self_status = get_self_attendee_status(event, user_id)
    if not self_status:
        return {"status": "pending", "reason": "self_status_missing"}
    if self_status not in ACCEPTED_STATUSES:
        return {"status": "failed", "reason": "self_not_accepted"}

    resource_state = classify_resource_attendees(event, expected_room_id)
    if resource_state["status"] == "confirmed":
        return {"status": "confirmed", "reason": "booking_confirmed"}
    if resource_state["status"] == "pending":
        return {"status": "pending", "reason": "room_pending"}
    return {"status": "failed", "reason": resource_state["reason"]}


path: scripts/manage_preferences.py
content: #!/usr/bin/env python3
"""
用户会议室偏好管理

用法:
  # 设置偏好
  python3 manage_preferences.py --set --user "ou_xxx" --building "丽金" --capacity-gte 8 --preferred-rooms "F11-07,F11-15" --note "偏好靠近电梯"

  # 读取偏好
  python3 manage_preferences.py --get --user "ou_xxx"

  # 学习偏好（根据历史选择自动更新）
  python3 manage_preferences.py --learn --user "ou_xxx" --room "F11-15(8)" --building "丽金智地中心 B座"

  # 列出所有用户偏好
  python3 manage_preferences.py --list

  # 删除偏好
  python3 manage_preferences.py --delete --user "ou_xxx"
"""

import argparse
import json
import sys
from pathlib import Path
from collections import Counter

SCRIPT_DIR = Path(__file__).parent
PREFS_FILE = SCRIPT_DIR.parent / "references" / "user-preferences.json"

LEARN_THRESHOLD = 3  # 连续选择同一会议室 3 次后自动标记为偏好


def load_prefs():
    if not PREFS_FILE.exists():
        return {"preferences": {}}
    with open(PREFS_FILE, "r", encoding="utf-8") as f:
        return json.load(f)


def save_prefs(data):
    with open(PREFS_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def set_preference(user_id, args):
    """设置用户偏好"""
    data = load_prefs()
    prefs = data.setdefault("preferences", {})
    prefs[user_id] = {
        "default_building": args.building or "",
        "capacity_gte": args.capacity_gte or 0,
        "capacity_lte": args.capacity_lte or 999,
        "preferred_rooms": [r.strip() for r in args.preferred_rooms.split(",")] if args.preferred_rooms else [],
        "note": args.note or "",
        "selection_history": prefs.get(user_id, {}).get("selection_history", [])
    }
    save_prefs(data)
    print(f"✅ 已保存 {user_id} 的偏好:")
    print(json.dumps(prefs[user_id], ensure_ascii=False, indent=2))


def get_preference(user_id):
    """获取用户偏好"""
    data = load_prefs()
    prefs = data.get("preferences", {}).get(user_id)
    if not prefs:
        print(f"❌ 未找到 {user_id} 的偏好设置")
        return None
    print(json.dumps(prefs, ensure_ascii=False, indent=2))
    return prefs


def learn_preference(user_id, room_name, building_name):
    """根据用户选择学习偏好"""
    data = load_prefs()
    prefs = data.setdefault("preferences", {})

    user_prefs = prefs.get(user_id, {
        "default_building": "",
        "capacity_gte": 0,
        "capacity_lte": 999,
        "preferred_rooms": [],
        "note": "",
        "selection_history": []
    })

    # 记录选择历史
    history = user_prefs.setdefault("selection_history", [])
    history.append({
        "room": room_name,
        "building": building_name,
        "time": __import__("datetime").datetime.now().isoformat()
    })
    # 只保留最近 20 条
    user_prefs["selection_history"] = history[-20:]

    # 更新默认楼栋（最近 3 次选择的楼栋）
    recent_buildings = [h["building"] for h in history[-3:] if h["building"]]
    if len(set(recent_buildings)) == 1 and recent_buildings[0]:
        user_prefs["default_building"] = recent_buildings[0]

    # 统计会议室选择频率
    room_counts = Counter(h["room"] for h in history)
    most_common = room_counts.most_common(3)

    # 更新偏好会议室（选择次数 >= LEARN_THRESHOLD 的）
    preferred = [room for room, count in most_common if count >= LEARN_THRESHOLD]
    if preferred:
        # 去重并保持顺序
        seen = set()
        unique_preferred = []
        for r in preferred:
            if r not in seen:
                seen.add(r)
                unique_preferred.append(r)
        user_prefs["preferred_rooms"] = unique_preferred

    prefs[user_id] = user_prefs
    save_prefs(data)

    print(f"📝 已记录 {user_id} 选择了 {room_name}（{building_name}）")
    if user_prefs.get("preferred_rooms"):
        print(f"   当前偏好会议室: {', '.join(user_prefs['preferred_rooms'])}")
    if user_prefs.get("default_building"):
        print(f"   当前默认楼栋: {user_prefs['default_building']}")


def list_preferences():
    """列出所有用户偏好"""
    data = load_prefs()
    prefs = data.get("preferences", {})
    if not prefs:
        print("还没有任何用户偏好设置")
        return
    for user_id, p in prefs.items():
        building = p.get("default_building", "未设置")
        cap = p.get("capacity_gte", 0)
        rooms = ", ".join(p.get("preferred_rooms", [])) or "无"
        print(f"👤 {user_id}  楼栋:{building}  容量≥{cap}  偏好:{rooms}")


def delete_preference(user_id):
    """删除用户偏好"""
    data = load_prefs()
    prefs = data.get("preferences", {})
    if user_id in prefs:
        del prefs[user_id]
        save_prefs(data)
        print(f"✅ 已删除 {user_id} 的偏好设置")
    else:
        print(f"❌ 未找到 {user_id} 的偏好设置")


def main():
    parser = argparse.ArgumentParser(description="用户会议室偏好管理")
    parser.add_argument("--set", action="store_true", help="设置偏好")
    parser.add_argument("--get", action="store_true", help="读取偏好")
    parser.add_argument("--learn", action="store_true", help="根据选择学习偏好")
    parser.add_argument("--list", action="store_true", help="列出所有偏好")
    parser.add_argument("--delete", action="store_true", help="删除偏好")
    parser.add_argument("--user", "-u", help="用户 open_id")
    parser.add_argument("--building", "-b", help="默认楼栋")
    parser.add_argument("--capacity-gte", "-c", type=int, help="最小容量")
    parser.add_argument("--capacity-lte", type=int, help="最大容量")
    parser.add_argument("--preferred-rooms", help="偏好会议室（逗号分隔）")
    parser.add_argument("--room", "-r", help="当前选择的会议室（用于学习）")
    parser.add_argument("--note", "-n", help="备注")

    args = parser.parse_args()

    if args.list:
        list_preferences()
    elif args.set:
        if not args.user:
            print("错误: --set 需要 --user")
            sys.exit(1)
        set_preference(args.user, args)
    elif args.get:
        if not args.user:
            print("错误: --get 需要 --user")
            sys.exit(1)
        get_preference(args.user)
    elif args.learn:
        if not args.user or not args.room:
            print("错误: --learn 需要 --user 和 --room")
            sys.exit(1)
        learn_preference(args.user, args.room, args.building or "")
    elif args.delete:
        if not args.user:
            print("错误: --delete 需要 --user")
            sys.exit(1)
        delete_preference(args.user)
    else:
        parser.print_help()


if __name__ == "__main__":
    main()


path: scripts/query_rooms.py
content: #!/usr/bin/env python3
"""
飞书会议室批量忙闲查询工具

用法:
  python3 query_rooms.py --building "丽金" --start "2026-04-20T14:00:00+08:00" --end "2026-04-20T15:00:00+08:00"
  python3 query_rooms.py --building "F4" --start "2026-04-20T09:00:00+08:00" --end "2026-04-20T18:00:00+08:00" --capacity-gte 8
  python3 query_rooms.py --list-buildings
  python3 query_rooms.py --list-rooms --building "丽金"

输出: JSON 格式，包含每个会议室的名称、room_id、容量、忙闲状态
"""

import argparse
import json
import subprocess
import sys
import re
import unicodedata
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date
from pathlib import Path
from typing import Any, Optional

SCRIPT_DIR = Path(__file__).parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

import workspace_manager

MAPPING_FILE = SCRIPT_DIR.parent / "references" / "room-mapping.json"
PREFS_FILE = SCRIPT_DIR.parent / "references" / "user-preferences.json"

# 预加载索引，模块级别缓存
_mapping_cache: Optional[dict] = None
_alias_index: Optional[dict[str, int]] = None
_building_search_cache: Optional[list[dict]] = None
GENERIC_LOCATION_TOKENS = {
    "会议室", "开会", "预订", "订", "找", "查", "room", "rooms", "meeting",
    "meetingroom", "office", "near", "附近", "的", "一个", "一下",
}



def normalize_text(text: str) -> str:
    normalized = unicodedata.normalize("NFKC", text).lower().strip()
    normalized = re.sub(r"[（）()\[\]{}]", " ", normalized)
    normalized = normalized.replace("－", "-")
    normalized = re.sub(r"\s+", " ", normalized)
    return normalized



def compact_text(text: str) -> str:
    normalized = normalize_text(text)
    normalized = re.sub(r"\bblock\s*([a-z0-9]+)", r"\1座", normalized)
    normalized = re.sub(r"\bbuilding\s*([a-z0-9]+)", r"\1座", normalized)
    normalized = re.sub(r"\btower\s*([a-z0-9]+)", r"\1座", normalized)
    normalized = re.sub(r"([a-z0-9])\s*座", r"\1座", normalized)
    normalized = re.sub(r"\s*[-_/]\s*", "-", normalized)
    return re.sub(r"[^\w一-鿿-]", "", normalized)



def strip_capacity_suffix(text: str) -> str:
    return re.sub(r"\(\d+\)$", "", text).strip()



def extract_tokens(text: str) -> list[str]:
    normalized = normalize_text(text)
    text_with_spaces = re.sub(r"([a-z0-9])\s*(楼|层|f|floor|座)", r"\1\2", normalized)
    chunks = re.split(r"[^\w一-鿿-]+", text_with_spaces)
    return [chunk for chunk in chunks if chunk and chunk not in GENERIC_LOCATION_TOKENS]



def infer_room_metadata(room_name: str, room_aliases: Optional[list[str]] = None) -> dict:
    aliases = room_aliases or []
    raw_terms = [room_name, strip_capacity_suffix(room_name), *aliases]
    compact_terms = {compact_text(term) for term in raw_terms if term}
    compact_terms = {term for term in compact_terms if term}

    floor_guess = None
    room_code = None
    for term in list(compact_terms):
        match = re.search(r"f?(\d{1,2})-([a-z0-9]{1,4})", term)
        if match:
            floor_guess = match.group(1)
            room_code = match.group(2)
            compact_terms.add(f"f{floor_guess}-{room_code}")
            compact_terms.add(f"{floor_guess}-{room_code}")
            break

    return {
        "floor_guess": floor_guess,
        "room_code": room_code,
        "compact_terms": compact_terms,
    }



def extract_location_hints(keyword: str) -> dict:
    compact_keyword = compact_text(keyword)
    floor_hint = None
    room_hint = None

    room_match = re.search(r"f?(\d{1,2})-([a-z0-9]{1,4})", compact_keyword)
    if room_match:
        floor_hint = room_match.group(1)
        room_code = room_match.group(2)
        room_hint = f"f{floor_hint}-{room_code}"
    else:
        floor_match = re.search(r"(\d{1,2})(?:楼|层|f|floor)", compact_keyword)
        if floor_match:
            floor_hint = floor_match.group(1)

        booth_match = re.search(r"([a-z0-9])(?:discussionbooth|booth)", compact_keyword)
        if booth_match:
            room_hint = f"{booth_match.group(1)}discussionbooth"

    building_query = compact_keyword
    if room_hint:
        building_query = building_query.replace(room_hint, "")
        room_code = room_hint.split("-", 1)[1] if "-" in room_hint else ""
        if room_code:
            building_query = building_query.replace(room_code, "")
    if floor_hint:
        building_query = re.sub(rf"f?{floor_hint}(?:楼|层|f|floor)?", "", building_query)
    building_query = building_query.replace("discussionbooth", "")

    tokens = []
    for token in extract_tokens(keyword):
        compact_token = compact_text(token)
        if not compact_token:
            continue
        if floor_hint and compact_token in {floor_hint, f"f{floor_hint}", f"{floor_hint}楼", f"{floor_hint}层"}:
            continue
        if room_hint and compact_token in {room_hint, room_hint.replace("f", "", 1)}:
            continue
        tokens.append(compact_token)

    if building_query:
        tokens.append(building_query)

    unique_tokens = []
    seen = set()
    for token in tokens:
        if not token or token in seen:
            continue
        seen.add(token)
        unique_tokens.append(token)

    return {
        "normalized": normalize_text(keyword),
        "compact": compact_keyword,
        "tokens": unique_tokens,
        "floor_hint": floor_hint,
        "room_hint": room_hint,
        "building_query": building_query,
    }



def _load_mapping():
    global _mapping_cache, _alias_index, _building_search_cache
    if _mapping_cache is not None:
        return _mapping_cache, _alias_index, _building_search_cache

    with open(MAPPING_FILE, "r", encoding="utf-8") as f:
        _mapping_cache = json.load(f)

    _alias_index = {}
    _building_search_cache = []
    for i, building in enumerate(_mapping_cache.get("buildings", [])):
        searchable_aliases = [building["name"], *building.get("alias", [])]
        for alias in searchable_aliases:
            key = alias.lower()
            if key not in _alias_index:
                _alias_index[key] = i

        room_entries = []
        for room in building.get("rooms", []):
            metadata = infer_room_metadata(room["name"], room.get("alias", []))
            room_entries.append({
                "room": room,
                "metadata": metadata,
            })

        _building_search_cache.append({
            "index": i,
            "building": building,
            "aliases": searchable_aliases,
            "alias_compacts": [compact_text(alias) for alias in searchable_aliases if alias],
            "alias_tokens": extract_tokens(" ".join(searchable_aliases)),
            "room_entries": room_entries,
        })

    return _mapping_cache, _alias_index, _building_search_cache



def resolve_location_query(keyword: str) -> dict:
    data, _, search_entries = _load_mapping()
    buildings = data.get("buildings", [])
    hints = extract_location_hints(keyword)
    ranked_matches = []
    minimum_score = 40 if hints["building_query"] else 20

    for entry in search_entries:
        score = 0
        alias_compacts = entry["alias_compacts"]
        building_query = hints["building_query"]
        matched_room_terms = False
        matched_floor = False

        if hints["compact"] in alias_compacts:
            score += 100
        elif building_query and building_query in alias_compacts:
            score += 95
        elif building_query and any(building_query in alias for alias in alias_compacts):
            score += 75
        elif building_query and any(alias and len(alias) >= 4 and alias in building_query for alias in alias_compacts):
            score += 75
        elif any(hints["compact"] and hints["compact"] in alias for alias in alias_compacts):
            score += 60

        token_score = 0
        for token_compact in hints["tokens"]:
            if any(token_compact in alias for alias in alias_compacts):
                token_score += 12
        score += token_score

        for room_entry in entry["room_entries"]:
            metadata = room_entry["metadata"]
            if hints["room_hint"] and hints["room_hint"] in metadata["compact_terms"]:
                matched_room_terms = True
                score += 80
            if hints["floor_hint"] and metadata["floor_guess"] == hints["floor_hint"]:
                matched_floor = True

        if matched_floor:
            score += 20
        if matched_room_terms and matched_floor:
            score += 20

        if score >= minimum_score:
            ranked_matches.append((score, entry["index"]))

    ranked_matches.sort(key=lambda item: (-item[0], item[1]))
    matched_indices = [index for _, index in ranked_matches]
    if not matched_indices:
        return {
            "matched_buildings": [],
            "room_filter": {"floor_hint": hints["floor_hint"], "room_hint": hints["room_hint"]},
        }

    return {
        "matched_buildings": [buildings[index] for index in matched_indices],
        "room_filter": {"floor_hint": hints["floor_hint"], "room_hint": hints["room_hint"]},
    }



def match_building(keyword: str) -> list[dict]:
    """根据关键词匹配楼栋，支持楼层和房间号等自然语言变体。"""
    return resolve_location_query(keyword)["matched_buildings"]



def filter_rooms_by_location_hints(rooms: list[dict], room_filter: Optional[dict]) -> list[dict]:
    if not room_filter:
        return rooms

    floor_hint = room_filter.get("floor_hint")
    room_hint = room_filter.get("room_hint")
    room_matches = []
    floor_matches = []

    for room in rooms:
        metadata = infer_room_metadata(room["name"], room.get("alias", []))
        if room_hint and room_hint in metadata["compact_terms"]:
            room_matches.append(room)
        if floor_hint and metadata["floor_guess"] == floor_hint:
            floor_matches.append(room)

    if room_matches:
        if floor_hint:
            narrowed = [room for room in room_matches if infer_room_metadata(room["name"], room.get("alias", [])).get("floor_guess") == floor_hint]
            if narrowed:
                return narrowed
        return room_matches

    if floor_matches:
        return floor_matches

    return rooms


def query_freebusy(room_id: str, time_min: str, time_max: str) -> dict:
    """查询单个会议室的忙闲状态"""
    cmd = [
        "lark-cli", "calendar", "freebusys", "list", "--as", "bot",
        "--data", json.dumps({
            "room_id": room_id,
            "time_min": time_min,
            "time_max": time_max
        })
    ]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
        if result.returncode != 0:
            return {"status": "error", "busy_periods": []}
        data = json.loads(result.stdout)
        fb_list = data.get("data", {}).get("freebusy_list", [])
        return {
            "status": "busy" if fb_list else "free",
            "busy_periods": fb_list
        }
    except (subprocess.TimeoutExpired, json.JSONDecodeError):
        return {"status": "error", "busy_periods": []}


def compute_duration_minutes(time_min: str, time_max: str) -> int:
    start_minutes = parse_minutes(time_min)
    end_minutes = parse_minutes(time_max)
    return max(end_minutes - start_minutes, 0)



def query_freebusy_parallel(rooms: list[dict], time_min: str, time_max: str,
                            max_workers: int = 10) -> list[dict]:
    """并行查询多个会议室的忙闲状态"""
    results = []

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_map = {
            executor.submit(query_freebusy, r["room_id"], time_min, time_max): r
            for r in rooms
        }
        done = 0
        total = len(rooms)
        for future in as_completed(future_map):
            room = future_map[future]
            fb = future.result()
            done += 1
            if total > 20 and done % 20 == 0:
                print(f"\r  查询进度: {done}/{total}", file=sys.stderr, end="", flush=True)

            result = {
                "name": room["name"],
                "room_id": room["room_id"],
                "capacity": room["capacity"],
                "building": room["building"],
                "status": fb["status"],
            }
            if fb["status"] == "free":
                result["free_slots"] = [{
                    "start": time_min,
                    "end": time_max,
                    "duration_min": compute_duration_minutes(time_min, time_max),
                }]
            elif fb["status"] == "busy":
                result["free_slots"] = compute_free_slots(fb["busy_periods"], time_min, time_max)
            else:
                result["free_slots"] = []
            results.append(result)

    if total > 20:
        print(file=sys.stderr, flush=True)
    return results


def parse_minutes(value: str) -> int:
    match = re.match(r"(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})", value)
    if match:
        return int(match.group(4)) * 60 + int(match.group(5))
    return 0



def compute_free_slots(busy_periods: list[dict], time_min: str, time_max: str) -> list[dict]:
    """从忙碌时段计算空闲时段"""
    if not busy_periods:
        return [{
            "start": time_min,
            "end": time_max,
            "duration_min": compute_duration_minutes(time_min, time_max),
        }]

    gaps = []
    sorted_busy = sorted(busy_periods, key=lambda x: x["start_time"])
    last_end = time_min

    for period in sorted_busy:
        if period["start_time"] > last_end:
            duration = parse_minutes(period["start_time"]) - parse_minutes(last_end)
            gaps.append({
                "start": last_end,
                "end": period["start_time"],
                "duration_min": duration
            })
        last_end = max(last_end, period["end_time"])

    if last_end < time_max:
        duration = parse_minutes(time_max) - parse_minutes(last_end)
        gaps.append({
            "start": last_end,
            "end": time_max,
            "duration_min": duration
        })

    return sorted(gaps, key=lambda x: -x["duration_min"])


def load_preferences(filepath: Optional[Path] = None) -> dict[str, Any]:
    resolved_path = filepath or PREFS_FILE
    if not resolved_path.exists():
        return {"preferences": {}}
    with open(resolved_path, "r", encoding="utf-8") as file:
        return json.load(file)


def parse_target_date(value: Optional[str]) -> str:
    if not value:
        return date.today().isoformat()
    return value[:10]


def get_default_building_for_user(user_id: Optional[str], target_date: Optional[str] = None) -> str:
    if not user_id:
        return ""

    preferences = load_preferences().get("preferences", {})
    user_preferences = preferences.get(user_id, {})
    default_building = user_preferences.get("default_building", "")
    if isinstance(default_building, str) and default_building:
        return default_building

    workspace_data = workspace_manager.load_workspace()
    resolved_workspace = workspace_manager.resolve_workspace_for_date(
        workspace_data,
        target_date or date.today().isoformat(),
    )
    return resolved_workspace or ""


def resolve_requested_building(building: Optional[str], user_id: Optional[str], target_date: Optional[str] = None) -> str:
    if building:
        return building
    return get_default_building_for_user(user_id, target_date)


def main() -> None:
    parser = argparse.ArgumentParser(description="飞书会议室忙闲查询")
    parser.add_argument("--building", "-b", help="楼栋关键词（模糊匹配）")
    parser.add_argument("--user", "-u", help="用户 open_id；未指定楼栋时用于读取偏好或工区")
    parser.add_argument("--start", "-s", help="查询开始时间 (ISO 8601)")
    parser.add_argument("--end", "-e", help="查询结束时间 (ISO 8601)")
    parser.add_argument("--capacity-gte", "-c", type=int, default=0, help="最小容量筛选")
    parser.add_argument("--capacity-lte", type=int, default=999, help="最大容量筛选")
    parser.add_argument("--list-buildings", action="store_true", help="列出所有楼栋")
    parser.add_argument("--list-rooms", action="store_true", help="列出指定楼栋的会议室")
    parser.add_argument("--output", "-o", choices=["json", "table"], default="json", help="输出格式")
    parser.add_argument("--max-workers", "-w", type=int, default=10,
                        help="并行查询线程数 (默认 10)")

    args = parser.parse_args()
    data, _, _ = _load_mapping()

    if args.list_buildings:
        for b in data.get("buildings", []):
            rooms_count = len(b.get("rooms", []))
            aliases = ", ".join(b.get("alias", []))
            print(f"📍 {b['name']}  ({rooms_count}个会议室)  别名: {aliases}")
        return

    resolved_target_date = parse_target_date(args.start)
    resolved_building = resolve_requested_building(args.building, args.user, resolved_target_date)

    if args.list_rooms:
        if not resolved_building:
            print("错误: --list-rooms 需要指定 --building，或通过 --user 提供可用的偏好/工区默认值", file=sys.stderr)
            sys.exit(1)
        location_match = resolve_location_query(resolved_building)
        matched = location_match["matched_buildings"]
        if not matched:
            print(f"未找到匹配的楼栋: {resolved_building}", file=sys.stderr)
            sys.exit(1)
        for b in matched:
            print(f"\n🏢 {b['name']}:")
            rooms = filter_rooms_by_location_hints(b.get("rooms", []), location_match["room_filter"])
            for r in rooms:
                print(f"  {r['name']}  容量:{r['capacity']}  room_id:{r['room_id']}")
        return

    if not resolved_building:
        print("错误: 必须指定 --building，或通过 --user 提供可用的偏好/工区默认值", file=sys.stderr)
        sys.exit(1)
    if not args.start or not args.end:
        print("错误: 必须同时指定 --start 和 --end", file=sys.stderr)
        sys.exit(1)

    location_match = resolve_location_query(resolved_building)
    matched = location_match["matched_buildings"]
    if not matched:
        print(f"未找到匹配的楼栋: {resolved_building}", file=sys.stderr)
        print("\n可用楼栋:", file=sys.stderr)
        for b in data.get("buildings", []):
            print(f"  - {b['name']}", file=sys.stderr)
        sys.exit(1)

    all_rooms = []
    for b in matched:
        rooms = filter_rooms_by_location_hints(b.get("rooms", []), location_match["room_filter"])
        for r in rooms:
            cap = r.get("capacity", 0)
            if args.capacity_gte <= cap <= args.capacity_lte:
                all_rooms.append({
                    "name": r["name"],
                    "room_id": r["room_id"],
                    "capacity": cap,
                    "building": b["name"]
                })

    if not all_rooms:
        print(f"没有符合条件的会议室 (容量 >= {args.capacity_gte}, <= {args.capacity_lte})")
        sys.exit(0)

    room_count = len(all_rooms)
    if room_count > 20:
        print(f"🔍 正在查询 {room_count} 个会议室...", file=sys.stderr)

    results = query_freebusy_parallel(
        all_rooms, args.start, args.end, max_workers=args.max_workers
    )

    free_rooms = [r for r in results if r["status"] == "free"]
    busy_rooms = [r for r in results if r["status"] != "free"]
    free_rooms.sort(key=lambda x: -x["capacity"])
    busy_rooms.sort(key=lambda x: x["name"])
    results = free_rooms + busy_rooms

    if args.output == "table":
        isatty = sys.stdout.isatty()
        print(f"\n{'会议室':<35} {'容量':>4}  {'状态':<6}  空闲时段")
        print("-" * 80)
        for r in results:
            if isatty:
                icon = "🟢" if r["status"] == "free" else ("🔴" if r["status"] == "busy" else "⚠️")
                name_col = f"{icon} {r['name']}"
            else:
                name_col = r["name"]
            slots_str = ", ".join(
                [f"{s['start'][11:16]}-{s['end'][11:16]}" for s in r["free_slots"]]
            ) if r["free_slots"] else "-"
            print(f"{name_col:<35} {r['capacity']:>4}人  {r['status']:<6}  {slots_str}")
        free_count = len(free_rooms)
        total = len(results)
        print(f"\n共 {total} 个会议室，{free_count} 个空闲")
    else:
        print(json.dumps(results, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

path: scripts/scan_events.py
content: #!/usr/bin/env python3
"""
扫描用户日程，找出缺少会议室的日程并自动补订

用法:
  # 扫描未来24小时缺会议室的日程（dry-run 模式，不实际预订）
  python3 scan_events.py --user "ou_xxx" --hours 24 --dry-run

  # 扫描并自动补订（使用用户偏好）
  python3 scan_events.py --user "ou_xxx" --hours 24 --auto-book

  # 扫描指定日期范围
  python3 scan_events.py --user "ou_xxx" --start "2026-04-20T00:00:00+08:00" --end "2026-04-20T23:59:59+08:00" --auto-book

输出: JSON 格式，列出缺会议室的日程和建议方案
"""

import argparse
import json
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional

SCRIPT_DIR = Path(__file__).parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

import workspace_manager
from booking_verifier import (
    classify_event_for_scan,
    get_self_attendee_status,
    get_attendee_identifier,
    is_self_attendee,
    normalize_status_value,
)

PREFS_FILE = SCRIPT_DIR.parent / "references" / "user-preferences.json"
WAITLIST_FILE = SCRIPT_DIR.parent / "references" / "room-waitlist.json"


def load_json(filepath: Path, default: Optional[dict] = None) -> dict:
    if not filepath.exists():
        return default or {}
    with open(filepath, "r", encoding="utf-8") as f:
        return json.load(f)


def save_json(filepath: Path, data: dict) -> None:
    with open(filepath, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def get_user_preferences(user_id: str) -> dict:
    """读取用户偏好"""
    prefs = load_json(PREFS_FILE, {"preferences": {}})
    return prefs.get("preferences", {}).get(user_id, {})


def get_default_building_for_user(user_id: str, target_date: Optional[str] = None) -> str:
    preferences = get_user_preferences(user_id)
    default_building = preferences.get("default_building", "")
    if isinstance(default_building, str) and default_building:
        return default_building

    workspace_data = workspace_manager.load_workspace()
    resolved_workspace = workspace_manager.resolve_workspace_for_date(
        workspace_data,
        target_date or datetime.now().date().isoformat(),
    )
    return resolved_workspace or ""


def parse_events_from_list_output(output: Any) -> list[dict[str, Any]]:
    """从 feishu_calendar_event list 的输出中解析日程"""
    try:
        data = json.loads(output) if isinstance(output, str) else output
    except json.JSONDecodeError:
        return []

    if not isinstance(data, dict):
        return []

    events = data.get("events", [])
    if not isinstance(events, list):
        return []
    return [event for event in events if isinstance(event, dict)]


def get_event_datetime(value: Any) -> str:
    if isinstance(value, str):
        return value
    if isinstance(value, dict):
        datetime_value = value.get("datetime")
        if isinstance(datetime_value, str):
            return datetime_value
        date_value = value.get("date")
        if isinstance(date_value, str):
            return date_value
    return ""



def parse_event_datetime(value: str) -> Optional[datetime]:
    if not isinstance(value, str) or len(value) == 10:
        return None
    normalized = value.replace("Z", "+00:00")
    try:
        return datetime.fromisoformat(normalized)
    except ValueError:
        return None



def is_all_day_event(event: dict) -> bool:
    start = get_event_datetime(event.get("start_time"))
    end = get_event_datetime(event.get("end_time"))
    if not start:
        return False
    if len(start) == 10 or len(end) == 10:
        return True

    start_dt = parse_event_datetime(start)
    end_dt = parse_event_datetime(end)
    if not start_dt or not end_dt:
        return False

    return (
        start_dt.date() == end_dt.date()
        and start_dt.hour == 0
        and start_dt.minute == 0
        and start_dt.second == 0
        and end_dt.hour == 23
        and end_dt.minute == 59
        and end_dt.second == 59
    )



SCAN_REASON_MESSAGES = {
    "self_not_accepted": (False, "当前用户尚未接受会议"),
    "self_status_missing": (False, "当前用户接受状态待确认"),
    "already_has_confirmed_room": (False, "已有会议室"),
    "room_pending": (False, "会议室状态待确认"),
    "room_declined": (True, "会议室预订失败，需要补订"),
    "needs_room": (True, "需要补订会议室"),
}


PENDING_SCAN_REASONS = {"self_status_missing", "room_pending"}


def evaluate_scan_result(event: dict, user_id: str) -> dict[str, Any]:
    if is_all_day_event(event):
        return {
            "should_book": False,
            "verification_status": "failed",
            "reason_code": "all_day_event",
            "reason": "",
        }

    classification = classify_event_for_scan(event, user_id)
    should_book, reason = SCAN_REASON_MESSAGES.get(
        classification["reason"],
        (False, "会议状态待确认"),
    )
    return {
        "should_book": should_book,
        "verification_status": classification["status"],
        "reason_code": classification["reason"],
        "reason": reason,
    }


def needs_room(event: dict, user_id: str) -> tuple[bool, str]:
    """判断日程是否需要补订会议室"""
    result = evaluate_scan_result(event, user_id)
    return result["should_book"], result["reason"]


def main() -> None:
    parser = argparse.ArgumentParser(description="扫描缺会议室的日程")
    parser.add_argument("--user", "-u", help="用户 open_id")
    parser.add_argument("--hours", type=int, default=24, help="扫描未来 N 小时")
    parser.add_argument("--start", "-s", help="开始时间")
    parser.add_argument("--end", "-e", help="结束时间")
    parser.add_argument("--dry-run", action="store_true", help="只扫描不预订")
    parser.add_argument("--auto-book", action="store_true", help="自动补订会议室")
    parser.add_argument("--events-file", help="日程 JSON 文件路径（agent 传入）")
    parser.add_argument("--output", "-o", choices=["json", "table"], default="table")

    args = parser.parse_args()

    # 计算时间范围
    now = datetime.now()
    if args.start:
        time_min = args.start
    else:
        time_min = (now + timedelta(hours=0)).strftime("%Y-%m-%dT%H:%M:%S+08:00")

    if args.end:
        time_max = args.end
    else:
        time_max = (now + timedelta(hours=args.hours)).strftime("%Y-%m-%dT%H:%M:%S+08:00")

    if not args.user:
        print("错误: 必须指定 --user")
        sys.exit(1)

    # 获取用户偏好
    prefs = get_user_preferences(args.user)
    default_building = get_default_building_for_user(args.user, time_min[:10])
    capacity_gte = prefs.get("capacity_gte", 0)

    # 注意：实际日程获取需要通过飞书 API 或工具
    # 这里输出扫描参数，由 agent 调用飞书工具获取日程后传入
    scan_config = {
        "user_id": args.user,
        "time_min": time_min,
        "time_max": time_max,
        "default_building": default_building,
        "capacity_gte": capacity_gte,
        "dry_run": args.dry_run,
        "auto_book": args.auto_book,
    }

    if args.output != "json":
        print(f"🔍 扫描日程: {time_min} ~ {time_max}")
        if default_building:
            print(f"🏠 默认楼栋: {default_building} (容量≥{capacity_gte})")
        print()
        print("📋 扫描配置:")
        print(json.dumps(scan_config, ensure_ascii=False, indent=2))
        print()
        print("⚠️ 此脚本需要配合 agent 使用。agent 应:")
        print("  1. 调用 feishu_calendar_event list 获取日程")
        print("  2. 传入 --events-file 参数进行分析")
        print()
        print("完整用法: 先导出日程到 JSON，再传入分析")
        print(f"  feishu_calendar_event list → 保存到 /tmp/events.json")
        print(f"  python3 scan_events.py --user {args.user} --events-file /tmp/events.json --dry-run")

    # 支持 events-file 模式：agent 传入已获取的日程 JSON
    if args.events_file:
        try:
            with open(args.events_file, "r", encoding="utf-8") as f:
                raw_events = json.load(f)
                events = raw_events if isinstance(raw_events, list) else parse_events_from_list_output(raw_events)
        except (OSError, json.JSONDecodeError) as exc:
            print(f"错误: 无法读取 events 文件: {exc}", file=sys.stderr)
            sys.exit(1)

        needs = []
        pending_verification = []
        for event in events:
            scan_result = evaluate_scan_result(event, args.user)
            if scan_result["should_book"]:
                needs.append({
                    "event_id": event.get("event_id", ""),
                    "summary": event.get("summary", ""),
                    "start_time": event.get("start_time", ""),
                    "end_time": event.get("end_time", ""),
                    "organizer": event.get("event_organizer", {}).get("display_name", ""),
                    "reason": scan_result["reason"],
                    "verification_status": scan_result["verification_status"],
                })
                continue

            if scan_result["reason_code"] in PENDING_SCAN_REASONS:
                pending_verification.append({
                    "event_id": event.get("event_id", ""),
                    "summary": event.get("summary", ""),
                    "start_time": event.get("start_time", ""),
                    "end_time": event.get("end_time", ""),
                    "organizer": event.get("event_organizer", {}).get("display_name", ""),
                    "reason": scan_result["reason"],
                    "verification_status": scan_result["verification_status"],
                })

        if args.output == "json":
            print(json.dumps({
                "scan_config": scan_config,
                "needs": needs,
                "pending_verification": pending_verification,
            }, ensure_ascii=False, indent=2))
            return

        if not needs and not pending_verification:
            print("✅ 所有日程都已安排会议室")
            return

        if needs:
            print(f"找到 {len(needs)} 个需要补订会议室的日程:")
            for n in needs:
                print(f"  📌 {n['summary']}")
                print(f"     时间: {n['start_time']} ~ {n['end_time']}")
                print(f"     组织者: {n['organizer']}")
                print(f"     状态: {n['verification_status']}")

        if pending_verification:
            print(f"\n有 {len(pending_verification)} 个日程需要二次确认:")
            for item in pending_verification:
                print(f"  ⏳ {item['summary']}")
                print(f"     时间: {item['start_time']} ~ {item['end_time']}")
                print(f"     原因: {item['reason']}")

    if args.output == "json":
        print(json.dumps({"scan_config": scan_config}, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/watch_waitlist.py
content: #!/usr/bin/env python3
"""
候补会议室轮询 - 定期检查候补队列中的日程是否有空闲会议室

用法:
  # 检查候补队列状态
  python3 watch_waitlist.py --status

  # 执行一轮轮询（尝试为候补日程预订会议室）
  python3 watch_waitlist.py --poll

  # 添加到候补队列
  python3 watch_waitlist.py --add --event-id "xxx" --summary "周会" \
    --start "2026-04-20T14:00:00+08:00" --end "2026-04-20T15:00:00+08:00" \
    --building "丽金" --capacity-gte 8

  # 移除候补
  python3 watch_waitlist.py --remove --event-id "xxx"

  # 清理已过期的候补
  python3 watch_waitlist.py --clean
"""

import argparse
import json
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional

SCRIPT_DIR = Path(__file__).parent
WAITLIST_FILE = SCRIPT_DIR.parent / "references" / "room-waitlist.json"
QUERY_SCRIPT = SCRIPT_DIR / "query_rooms.py"
WaitlistItem = dict[str, Any]
WaitlistData = dict[str, list[WaitlistItem]]


def load_waitlist() -> WaitlistData:
    if not WAITLIST_FILE.exists():
        return {"pending": []}
    with open(WAITLIST_FILE, "r", encoding="utf-8") as f:
        data = json.load(f)

    if not isinstance(data, dict):
        raise ValueError("候补队列文件格式错误: 顶层必须是对象")
    pending = data.get("pending", [])
    if not isinstance(pending, list):
        raise ValueError("候补队列文件格式错误: pending 必须是数组")

    normalized_pending = [item for item in pending if isinstance(item, dict)]
    if len(normalized_pending) != len(pending):
        raise ValueError("候补队列文件格式错误: pending 项必须是对象")
    return {"pending": normalized_pending}


def save_waitlist(data: WaitlistData) -> None:
    with open(WAITLIST_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def show_status() -> None:
    """查看候补队列状态"""
    data = load_waitlist()
    pending = data.get("pending", [])
    if not pending:
        print("📭 候补队列为空")
        return
    print(f"📋 候补队列 ({len(pending)} 条):")
    for i, item in enumerate(pending, 1):
        event_id = item.get("event_id", "unknown")
        summary = item.get("summary", "")
        start = item.get("time", "")
        building = item.get("building", "")
        attempts = item.get("attempts", 0)
        print(f"  {i}. {summary}")
        print(f"     时间: {start}")
        print(f"     楼栋: {building}")
        print(f"     尝试次数: {attempts}")


def add_waitlist(args: argparse.Namespace) -> None:
    """添加到候补队列"""
    data = load_waitlist()
    pending = data.setdefault("pending", [])

    # 检查是否已存在
    event_id = args.event_id
    for item in pending:
        if item.get("event_id") == event_id:
            print(f"⚠️ {event_id} 已在候补队列中")
            return

    pending.append({
        "event_id": event_id,
        "summary": args.summary or "",
        "start": args.start,
        "end": args.end,
        "building": args.building or "",
        "capacity_gte": args.capacity_gte or 0,
        "attempted_rooms": [],
        "attempts": 0,
        "added_at": datetime.now().isoformat(),
        "status": "waiting"
    })
    save_waitlist(data)
    print(f"✅ 已加入候补: {args.summary} ({args.start})")


def remove_waitlist(event_id: str) -> None:
    """从候补队列移除"""
    data = load_waitlist()
    pending = data.get("pending", [])
    new_pending = [item for item in pending if item.get("event_id") != event_id]
    if len(new_pending) == len(pending):
        print(f"⚠️ {event_id} 不在候补队列中")
        return
    data["pending"] = new_pending
    save_waitlist(data)
    print(f"✅ 已从候补移除: {event_id}")


def clean_expired() -> None:
    """清理已过期的候补"""
    data = load_waitlist()
    pending = data.get("pending", [])
    now = datetime.now(timezone.utc)

    # 简单判断：如果 start 时间已过去 1 小时，认为过期
    active = []
    removed = 0
    for item in pending:
        start_str = item.get("start", "")
        if start_str:
            try:
                start_dt = datetime.fromisoformat(start_str)
                if start_dt.tzinfo is None:
                    start_dt = start_dt.replace(tzinfo=timezone.utc)
                if start_dt < now - timedelta(hours=1):
                    removed += 1
                    continue
            except ValueError:
                pass
        active.append(item)

    if removed > 0:
        data["pending"] = active
        save_waitlist(data)
        print(f"🧹 清理了 {removed} 条过期候补")
    else:
        print("✅ 没有过期候补")


def apply_verification_result(
    item: dict[str, Any],
    verification_status: str,
    verification_reason: str,
    *,
    verified_at: Optional[str] = None,
) -> tuple[Optional[dict[str, Any]], bool]:
    updated_item = dict(item)
    timestamp = verified_at or datetime.now().isoformat()
    updated_item["last_verification_status"] = verification_status
    updated_item["last_verification_reason"] = verification_reason
    updated_item["last_verified_at"] = timestamp

    if verification_status == "confirmed":
        return None, True

    if verification_status == "pending":
        updated_item["status"] = "verification_pending"
        return updated_item, False

    attempted_rooms = [room_id for room_id in updated_item.get("attempted_rooms", []) if room_id]
    suggested_room_id = updated_item.get("suggested_room_id")
    if suggested_room_id and suggested_room_id not in attempted_rooms:
        attempted_rooms.append(suggested_room_id)
    updated_item["attempted_rooms"] = attempted_rooms
    updated_item["status"] = "waiting"
    updated_item.pop("suggested_room", None)
    updated_item.pop("suggested_room_id", None)
    return updated_item, False


def poll_waitlist() -> None:
    """执行一轮候补轮询"""
    data = load_waitlist()
    pending = data.get("pending", [])
    if not pending:
        print("📭 候补队列为空，无需轮询")
        return

    updated = False
    results = []

    for item in pending:
        if item.get("status") not in {"waiting", "verification_pending"}:
            continue

        event_id = item["event_id"]
        start = item.get("start", "")
        end = item.get("end", "")
        building = item.get("building", "")
        cap_gte = item.get("capacity_gte", 0)
        attempted = set(item.get("attempted_rooms", []))

        print(f"\n🔍 检查: {item.get('summary', event_id)}")
        print(f"   时间: {start} ~ {end}")

        # 用查询脚本查空闲会议室
        cmd = [
            sys.executable, str(QUERY_SCRIPT),
            "-b", building,
            "-s", start,
            "-e", end,
            "-o", "json"
        ]
        if cap_gte:
            cmd.extend(["--capacity-gte", str(cap_gte)])

        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
            if result.returncode != 0:
                print(f"   ❌ 查询失败: {result.stderr}")
                item["attempts"] = item.get("attempts", 0) + 1
                updated = True
                continue

            rooms = json.loads(result.stdout)
            # 找到空闲且未尝试过的会议室
            free_room = None
            for room in rooms:
                if room.get("status") == "free" and room.get("room_id") not in attempted:
                    free_room = room
                    break

            if free_room:
                room_name = free_room["name"]
                room_id = free_room["room_id"]
                print(f"   🟢 找到空闲: {room_name}")
                print(f"   ⏳ 等待 agent 执行预订...")
                item["status"] = "ready"
                item["suggested_room"] = room_name
                item["suggested_room_id"] = room_id
                results.append({
                    "event_id": event_id,
                    "action": "book",
                    "room_name": room_name,
                    "room_id": room_id,
                    "summary": item.get("summary", "")
                })
            else:
                # 没有空闲，更新已尝试列表
                new_attempted = set(attempted)
                for room in rooms:
                    new_attempted.add(room.get("room_id", ""))
                item["attempted_rooms"] = list(new_attempted)
                item["attempts"] = item.get("attempts", 0) + 1
                print(f"   🔴 仍然没有空闲会议室 (已尝试 {item['attempts']} 次)")

            updated = True

        except subprocess.TimeoutExpired:
            print(f"   ⏱️ 查询超时")
            item["attempts"] = item.get("attempts", 0) + 1
            updated = True

    if updated:
        save_waitlist(data)

    if results:
        print(f"\n🎉 有 {len(results)} 个候补可以预订:")
        for r in results:
            print(f"  📌 {r['summary']} → {r['room_name']} ({r['room_id']})")
        print("\n请 agent 执行以下操作:")
        print("  1. 对每个 result，调用 feishu_calendar_event_attendee create 添加会议室")
        print("  2. 等待 5 秒验证 RSVP")
        print("  3. accept → 从候补移除；decline → 保持 waiting")
        print(json.dumps(results, ensure_ascii=False, indent=2))
    else:
        print("\n📭 本轮轮询没有可用会议室")


def main() -> None:
    parser = argparse.ArgumentParser(description="候补会议室轮询")
    parser.add_argument("--status", action="store_true", help="查看候补状态")
    parser.add_argument("--poll", action="store_true", help="执行一轮轮询")
    parser.add_argument("--add", action="store_true", help="添加候补")
    parser.add_argument("--remove", action="store_true", help="移除候补")
    parser.add_argument("--clean", action="store_true", help="清理过期候补")
    parser.add_argument("--event-id", help="日程 ID")
    parser.add_argument("--summary", help="会议标题")
    parser.add_argument("--start", "-s", help="开始时间")
    parser.add_argument("--end", "-e", help="结束时间")
    parser.add_argument("--building", "-b", help="楼栋")
    parser.add_argument("--capacity-gte", "-c", type=int, help="最小容量")

    args = parser.parse_args()

    if args.status:
        show_status()
    elif args.poll:
        poll_waitlist()
    elif args.add:
        if not args.event_id or not args.start or not args.end:
            print("错误: --add 需要 --event-id, --start, --end")
            sys.exit(1)
        add_waitlist(args)
    elif args.remove:
        if not args.event_id:
            print("错误: --remove 需要 --event-id")
            sys.exit(1)
        remove_waitlist(args.event_id)
    elif args.clean:
        clean_expired()
    else:
        parser.print_help()


if __name__ == "__main__":
    main()


path: scripts/workspace_manager.py
content: #!/usr/bin/env python3
"""
工区时间线管理

用法:
  python3 workspace_manager.py --get
  python3 workspace_manager.py --set --workspace "丽金智地中心 B座"
  python3 workspace_manager.py --set --workspace "紫金" --from "2026-04-30"
  python3 workspace_manager.py --set --workspace "紫金" --from "2026-04-30" --to "2026-05-02"
  python3 workspace_manager.py --set-next --workspace "紫金数码园4号楼"
  python3 workspace_manager.py --recommend
  python3 workspace_manager.py --check-friday-reminder
  python3 workspace_manager.py --clear-next
  python3 workspace_manager.py --timeline
"""

import argparse
import json
import sys
from collections import Counter
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Optional

SCRIPT_DIR = Path(__file__).parent
WORKSPACE_FILE = SCRIPT_DIR.parent / "references" / "weekly-workspace.json"
PREFS_FILE = SCRIPT_DIR.parent / "references" / "user-preferences.json"


def load_workspace(filepath: Optional[Path] = None) -> dict[str, Any]:
    resolved_path = filepath or WORKSPACE_FILE
    if not resolved_path.exists():
        return {"segments": [], "next_week": None}
    with open(resolved_path, "r", encoding="utf-8") as file:
        return json.load(file)


def save_workspace(data: dict[str, Any], filepath: Optional[Path] = None) -> None:
    resolved_path = filepath or WORKSPACE_FILE
    with open(resolved_path, "w", encoding="utf-8") as file:
        json.dump(data, file, ensure_ascii=False, indent=2)


def load_preferences(filepath: Optional[Path] = None) -> dict[str, Any]:
    resolved_path = filepath or PREFS_FILE
    if not resolved_path.exists():
        return {"preferences": {}}
    with open(resolved_path, "r", encoding="utf-8") as file:
        return json.load(file)


def today_string() -> str:
    return date.today().isoformat()


def week_start_for(day: date) -> date:
    return day - timedelta(days=day.weekday())


def week_end_for(day: date) -> date:
    return week_start_for(day) + timedelta(days=6)


def shift_date(date_str: str, days: int) -> str:
    return (date.fromisoformat(date_str) + timedelta(days=days)).isoformat()


def weekday_name(date_str: str) -> str:
    names = ["一", "二", "三", "四", "五", "六", "日"]
    try:
        day = date.fromisoformat(date_str)
    except ValueError:
        return ""
    return f"周{names[day.weekday()]}"


def find_segment(data: dict[str, Any], target_date: str) -> Optional[dict[str, Any]]:
    matches = [
        segment
        for segment in data.get("segments", [])
        if segment.get("from", "") <= target_date <= segment.get("to", "")
    ]
    if not matches:
        return None
    return max(matches, key=lambda segment: segment.get("to", ""))


def find_current_workspace(data: dict[str, Any], target_date: Optional[str] = None) -> Optional[str]:
    segment = find_segment(data, target_date or today_string())
    if not segment:
        return None
    workspace = segment.get("workspace")
    return workspace if isinstance(workspace, str) and workspace else None


def resolve_workspace_for_date(data: dict[str, Any], target_date: Optional[str] = None) -> Optional[str]:
    resolved_date = target_date or today_string()
    current_workspace = find_current_workspace(data, resolved_date)
    if current_workspace:
        return current_workspace

    next_week = data.get("next_week")
    if not isinstance(next_week, dict):
        return None

    workspace = next_week.get("workspace")
    week_start = next_week.get("week_start")
    if not isinstance(workspace, str) or not workspace:
        return None
    if not isinstance(week_start, str) or not week_start:
        return None

    try:
        week_start_date = date.fromisoformat(week_start)
        target = date.fromisoformat(resolved_date)
    except ValueError:
        return None

    if week_start_date <= target <= week_start_date + timedelta(days=6):
        return workspace
    return None


def segment_overlaps(segment: dict[str, Any], from_date: str, to_date: str) -> bool:
    return not (segment.get("to", "") < from_date or segment.get("from", "") > to_date)


def merge_segment(data: dict[str, Any], workspace: str, from_date: str, to_date: str) -> dict[str, Any]:
    preserved_segments: list[dict[str, Any]] = []
    for segment in data.get("segments", []):
        if not segment_overlaps(segment, from_date, to_date):
            preserved_segments.append(segment)
            continue

        segment_from = segment.get("from", "")
        segment_to = segment.get("to", "")
        if segment_from < from_date:
            preserved_segments.append({**segment, "to": shift_date(from_date, -1)})
        if segment_to > to_date:
            preserved_segments.append({**segment, "from": shift_date(to_date, 1)})

    merged_segments = [
        *preserved_segments,
        {
            "from": from_date,
            "to": to_date,
            "workspace": workspace,
            "set_at": datetime.now().isoformat(),
            "set_by": "manual",
        },
    ]
    return {
        **data,
        "segments": sorted(merged_segments, key=lambda segment: segment.get("from", "")),
    }


def recommend_workspace(data: dict[str, Any], limit: int = 30) -> tuple[Optional[str], list[tuple[str, int]]]:
    selections: list[str] = []
    for preference in data.get("preferences", {}).values():
        if not isinstance(preference, dict):
            continue
        history = preference.get("selection_history", [])
        if not isinstance(history, list):
            continue
        for item in history:
            if isinstance(item, dict):
                building = item.get("building")
                if isinstance(building, str) and building:
                    selections.append(building)

    counts = Counter(selections[-limit:])
    ranking = counts.most_common()
    return (ranking[0][0] if ranking else None, ranking)


def build_get_payload(workspace_data: dict[str, Any], target_date: Optional[str] = None) -> dict[str, Any]:
    resolved_date = target_date or today_string()
    current_segment = find_segment(workspace_data, resolved_date)
    next_week = workspace_data.get("next_week")
    return {
        "today": resolved_date,
        "current_workspace": resolve_workspace_for_date(workspace_data, resolved_date),
        "current_segment": current_segment,
        "next_week": next_week if isinstance(next_week, dict) else None,
    }


def print_get_payload(payload: dict[str, Any]) -> None:
    today = payload["today"]
    current_workspace = payload.get("current_workspace")
    current_segment = payload.get("current_segment") or {}
    next_week = payload.get("next_week") or {}

    print(f"📅 今天: {today} ({weekday_name(today)})")
    if current_workspace:
        print(f"📍 当前工区: {current_workspace}")
        if current_segment:
            print(f"   有效期: {current_segment.get('from', '?')} ~ {current_segment.get('to', '?')}")
    else:
        print("⚠️ 未设置当前工区")

    next_workspace = next_week.get("workspace") if isinstance(next_week, dict) else None
    next_week_start = next_week.get("week_start") if isinstance(next_week, dict) else None
    print()
    if next_workspace and next_week_start:
        print(f"📅 下周 ({next_week_start} 起):")
        print(f"   🏢 工区: {next_workspace}")
    else:
        print("📅 下周:")
        print("   ⚠️ 未设置下周工区")


def command_get() -> int:
    payload = build_get_payload(load_workspace())
    print_get_payload(payload)
    return 0


def command_set(workspace: str, from_date: Optional[str], to_date: Optional[str]) -> int:
    start_date = from_date or today_string()
    parsed_start = date.fromisoformat(start_date)
    end_date = to_date or week_end_for(parsed_start).isoformat()
    date.fromisoformat(end_date)
    if start_date > end_date:
        print("错误: --from 不能晚于 --to", file=sys.stderr)
        return 1

    updated_data = merge_segment(load_workspace(), workspace, start_date, end_date)
    save_workspace(updated_data)
    print(f"✅ 工区已设置: {workspace}")
    print(f"   有效期: {start_date} ~ {end_date}")
    return 0


def command_set_next(workspace: str) -> int:
    next_week_start = (week_start_for(date.today()) + timedelta(weeks=1)).isoformat()
    updated_data = {
        **load_workspace(),
        "next_week": {
            "week_start": next_week_start,
            "workspace": workspace,
            "set_at": datetime.now().isoformat(),
            "set_by": "manual",
        },
    }
    save_workspace(updated_data)
    print(f"✅ 下周工区已设置: {workspace}")
    print(f"   周起始: {next_week_start}")
    return 0


def command_clear_next() -> int:
    updated_data = {**load_workspace(), "next_week": None}
    save_workspace(updated_data)
    print("✅ 已清空下周工区设置")
    return 0


def command_recommend() -> int:
    recommendation, ranking = recommend_workspace(load_preferences())
    if not ranking:
        print("❌ 没有历史选择记录，无法推荐")
        return 1

    print("📊 工区使用统计（最近 30 次选择）:")
    for building, count in ranking:
        print(f"   {building}: {'█' * count} ({count})")
    print()
    print(f"💡 推荐工区: {recommendation}")
    return 0


def command_check_friday_reminder() -> int:
    next_week = load_workspace().get("next_week")
    has_next_week = isinstance(next_week, dict) and bool(next_week.get("workspace"))
    print("NO_REMINDER" if has_next_week else "NEED_REMINDER")
    return 0


def command_timeline() -> int:
    workspace_data = load_workspace()
    today = today_string()
    print("📅 工区时间线:")
    print(f"   今天: {today} ({weekday_name(today)})")
    segments = workspace_data.get("segments", [])
    if not segments:
        print("   (暂无工区设置)")
        return 0

    for segment in segments:
        marker = " ◄ 今天" if segment.get("from", "") <= today <= segment.get("to", "") else ""
        print(f"   {segment.get('from', '?')} ~ {segment.get('to', '?')}: {segment.get('workspace', '?')}{marker}")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="工区时间线管理")
    parser.add_argument("--get", action="store_true", help="获取当前工区")
    parser.add_argument("--set", action="store_true", help="设置工区")
    parser.add_argument("--set-next", action="store_true", help="设置下周工区")
    parser.add_argument("--recommend", action="store_true", help="推荐下周工区")
    parser.add_argument("--check-friday-reminder", action="store_true", help="检查是否需要周五提醒")
    parser.add_argument("--clear-next", action="store_true", help="清空下周工区")
    parser.add_argument("--timeline", action="store_true", help="查看工区时间线")
    parser.add_argument("--workspace", "-w", help="工区名称")
    parser.add_argument("--from", dest="from_date", help="开始日期")
    parser.add_argument("--to", dest="to_date", help="结束日期")

    args = parser.parse_args()

    try:
        if args.get:
            return command_get()
        if args.set:
            if not args.workspace:
                print("错误: --set 需要 --workspace", file=sys.stderr)
                return 1
            return command_set(args.workspace, args.from_date, args.to_date)
        if args.set_next:
            if not args.workspace:
                print("错误: --set-next 需要 --workspace", file=sys.stderr)
                return 1
            return command_set_next(args.workspace)
        if args.recommend:
            return command_recommend()
        if args.check_friday_reminder:
            return command_check_friday_reminder()
        if args.clear_next:
            return command_clear_next()
        if args.timeline:
            return command_timeline()
    except ValueError as exc:
        print(f"错误: {exc}", file=sys.stderr)
        return 1

    parser.print_help()
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: tests/test_query_rooms.py
content: import json
import subprocess
import sys
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path


MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "query_rooms.py"
SPEC = spec_from_file_location("query_rooms", MODULE_PATH)
query_rooms = module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(query_rooms)


WORKSPACE_MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "workspace_manager.py"
WORKSPACE_SPEC = spec_from_file_location("workspace_manager_for_query_tests", WORKSPACE_MODULE_PATH)
workspace_manager = module_from_spec(WORKSPACE_SPEC)
assert WORKSPACE_SPEC.loader is not None
WORKSPACE_SPEC.loader.exec_module(workspace_manager)


def building_names(buildings):
    return [building["name"] for building in buildings]


def room_names(rooms):
    return [room["name"] for room in rooms]


def test_match_building_keeps_current_alias_behavior():
    matched = query_rooms.match_building("丽金")

    assert any("丽金智地中心 B座" in name for name in building_names(matched))


def test_match_building_supports_building_with_floor_phrase():
    matched = query_rooms.match_building("丽金B座11楼")

    assert building_names(matched) == ["Beijing-Lijin Zhidi Center Building B(丽金智地中心 B座)"]


def test_match_building_supports_room_code_phrase():
    matched = query_rooms.match_building("F11-15")

    assert building_names(matched)[0] == "Beijing-Lijin Zhidi Center Building B(丽金智地中心 B座)"


def test_match_building_supports_city_and_building_phrase():
    matched = query_rooms.match_building("北京 国家会议中心二期F座 3楼")

    assert building_names(matched) == ["Beijing-China National Convention Center Block F(国家会议中心二期 F座)"]


def test_match_building_supports_room_alias_phrase():
    matched = query_rooms.match_building("A DiscussionBooth")

    assert any("国家会议中心二期 F座" in name for name in building_names(matched))


def test_filter_rooms_by_location_hints_keeps_only_target_floor_and_room():
    matched = query_rooms.match_building("丽金B座11楼 F11-15")
    building = matched[0]
    location_match = query_rooms.resolve_location_query("丽金B座11楼 F11-15")
    filtered = query_rooms.filter_rooms_by_location_hints(building.get("rooms", []), location_match["room_filter"])

    assert room_names(filtered) == ["F11-15(8)"]


def test_filter_rooms_by_location_hints_keeps_only_target_floor():
    matched = query_rooms.match_building("国家会议中心二期F座 3楼")
    building = matched[0]
    location_match = query_rooms.resolve_location_query("国家会议中心二期F座 3楼")
    filtered = query_rooms.filter_rooms_by_location_hints(building.get("rooms", []), location_match["room_filter"])

    assert filtered
    assert all(room["name"].startswith("F3-") for room in filtered)



def test_compute_free_slots_uses_actual_query_duration_when_room_is_fully_free():
    slots = query_rooms.compute_free_slots([], "2026-04-20T14:00:00+08:00", "2026-04-20T15:30:00+08:00")

    assert slots == [{"start": "2026-04-20T14:00:00+08:00", "end": "2026-04-20T15:30:00+08:00", "duration_min": 90}]



def test_resolve_requested_building_prefers_explicit_input(tmp_path):
    prefs_file = tmp_path / "user-preferences.json"
    prefs_file.write_text(json.dumps({
        "preferences": {
            "ou_self": {"default_building": "丽金智地中心 B座"}
        }
    }, ensure_ascii=False), encoding="utf-8")

    workspace_file = tmp_path / "weekly-workspace.json"
    workspace_file.write_text(json.dumps({
        "segments": [{"from": "2026-05-20", "to": "2026-05-25", "workspace": "紫金数码园4号楼"}],
        "next_week": None,
    }, ensure_ascii=False), encoding="utf-8")

    query_rooms.PREFS_FILE = prefs_file
    query_rooms.workspace_manager.WORKSPACE_FILE = workspace_file

    resolved = query_rooms.resolve_requested_building("国家会议中心二期 F座", "ou_self", "2026-05-20")

    assert resolved == "国家会议中心二期 F座"


def test_get_default_building_for_user_falls_back_to_workspace(tmp_path):
    prefs_file = tmp_path / "user-preferences.json"
    prefs_file.write_text(json.dumps({"preferences": {"ou_self": {}}}, ensure_ascii=False), encoding="utf-8")

    workspace_file = tmp_path / "weekly-workspace.json"
    workspace_file.write_text(json.dumps({
        "segments": [{"from": "2026-05-20", "to": "2026-05-25", "workspace": "丽金智地中心 B座"}],
        "next_week": None,
    }, ensure_ascii=False), encoding="utf-8")

    query_rooms.PREFS_FILE = prefs_file
    query_rooms.workspace_manager.WORKSPACE_FILE = workspace_file

    resolved = query_rooms.get_default_building_for_user("ou_self", "2026-05-20")

    assert resolved == "丽金智地中心 B座"


def test_get_default_building_for_user_uses_next_week_when_date_matches(tmp_path):
    prefs_file = tmp_path / "user-preferences.json"
    prefs_file.write_text(json.dumps({"preferences": {"ou_self": {}}}, ensure_ascii=False), encoding="utf-8")

    workspace_file = tmp_path / "weekly-workspace.json"
    workspace_file.write_text(json.dumps({
        "segments": [],
        "next_week": {"week_start": "2026-05-26", "workspace": "紫金数码园4号楼"},
    }, ensure_ascii=False), encoding="utf-8")

    query_rooms.PREFS_FILE = prefs_file
    query_rooms.workspace_manager.WORKSPACE_FILE = workspace_file

    resolved = query_rooms.get_default_building_for_user("ou_self", "2026-05-27")

    assert resolved == "紫金数码园4号楼"


def test_cli_requires_start_and_end_for_availability_query():
    result = subprocess.run(
        [
            sys.executable,
            str(MODULE_PATH),
            "--building",
            "丽金B座",
            "--output",
            "json",
        ],
        capture_output=True,
        text=True,
    )

    assert result.returncode == 1
    assert "必须同时指定 --start 和 --end" in result.stderr


path: tests/test_scan_events.py
content: import json
import subprocess
import sys
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path


MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "scan_events.py"
SPEC = spec_from_file_location("scan_events", MODULE_PATH)
scan_events = module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(scan_events)


def build_event(*, attendees=None, location=None, summary="项目例会", start_time="2026-04-20T14:00:00+08:00", end_time="2026-04-20T15:00:00+08:00"):
    return {
        "event_id": "evt_123",
        "summary": summary,
        "start_time": start_time,
        "end_time": end_time,
        "location": location or {"name": "丽金智地中心 B座"},
        "attendees": attendees or [],
    }


def test_needs_room_for_accepted_self_attendee():
    event = build_event(
        attendees=[
            {"is_self": True, "status": "accepted"},
            {"type": "user", "display_name": "Teammate"},
        ]
    )

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is True
    assert reason == "需要补订会议室"


def test_skips_event_when_self_attendee_not_accepted():
    event = build_event(attendees=[{"is_self": True, "status": "needs_action"}])

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is False
    assert reason == "当前用户尚未接受会议"


def test_reads_event_level_self_rsvp_status_first():
    event = build_event(
        attendees=[{"is_self": True, "rsvp_status": "needs_action"}],
        start_time={"datetime": "2026-04-20T14:00:00+08:00", "timezone": "Asia/Shanghai"},
        end_time={"datetime": "2026-04-20T15:00:00+08:00", "timezone": "Asia/Shanghai"},
    )
    event["self_rsvp_status"] = "accept"

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is True
    assert reason == "需要补订会议室"


def test_reads_response_status_for_self_attendee():
    event = build_event(attendees=[{"is_self": True, "response_status": "accept"}])

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is True
    assert reason == "需要补订会议室"


def test_reads_nested_response_status_for_self_attendee():
    event = build_event(attendees=[{"is_self": True, "response_status": {"status": "accepted"}}])

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is True
    assert reason == "需要补订会议室"


def test_matches_user_id_when_is_self_missing():
    event = build_event(attendees=[{"open_id": "ou_self", "status": "accepted"}])

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is True
    assert reason == "需要补订会议室"



def test_does_not_treat_other_organizer_status_as_self_status():
    event = build_event(
        attendees=[{"open_id": "ou_organizer", "status": "accepted"}],
    )
    event["event_organizer"] = {"open_id": "ou_organizer", "display_name": "Owner"}

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is False
    assert reason == "当前用户尚未接受会议"



def test_treats_current_user_as_accepted_when_user_is_organizer():
    event = build_event(attendees=[])
    event["event_organizer"] = {"open_id": "ou_self", "display_name": "Owner"}

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is True
    assert reason == "需要补订会议室"


def test_skips_when_room_resource_already_exists():
    event = build_event(
        attendees=[
            {"is_self": True, "status": "accepted"},
            {"type": "resource", "room_id": "omm_room_1", "status": "accepted"},
        ]
    )

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is False
    assert reason == "已有会议室"


def test_does_not_treat_pending_resource_as_existing_room():
    event = build_event(
        attendees=[
            {"is_self": True, "status": "accepted"},
            {"type": "resource", "room_id": "omm_room_1", "status": "needs_action"},
        ]
    )

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is False
    assert reason == "会议室状态待确认"


def test_treats_declined_resource_as_needing_rebooking():
    event = build_event(
        attendees=[
            {"is_self": True, "status": "accepted"},
            {"type": "resource", "room_id": "omm_room_1", "status": "declined"},
        ]
    )

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is True
    assert reason == "会议室预订失败，需要补订"


def test_books_accepted_event_even_with_online_location():
    event = build_event(
        attendees=[{"is_self": True, "status": "accepted"}],
        location={"name": "飞书视频会议"},
    )

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is True
    assert reason == "需要补订会议室"


def test_does_not_treat_multi_day_timed_event_as_all_day():
    event = build_event(
        attendees=[{"is_self": True, "status": "accepted"}],
        start_time={"datetime": "2026-06-19T00:00:00+08:00", "timezone": "Asia/Shanghai"},
        end_time={"datetime": "2026-06-21T23:59:59+08:00", "timezone": "Asia/Shanghai"},
        location={},
    )

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is True
    assert reason == "需要补订会议室"


def test_skips_all_day_event_with_date_only_fields():
    event = build_event(
        attendees=[{"is_self": True, "status": "accepted"}],
        start_time={"date": "2026-06-19"},
        end_time={"date": "2026-06-21"},
        location={},
    )

    need, reason = scan_events.needs_room(event, "ou_self")

    assert need is False
    assert reason == ""


def test_get_default_building_for_user_falls_back_to_workspace(tmp_path):
    prefs_file = tmp_path / "user-preferences.json"
    prefs_file.write_text(json.dumps({"preferences": {"ou_self": {}}}, ensure_ascii=False), encoding="utf-8")

    workspace_file = tmp_path / "weekly-workspace.json"
    workspace_file.write_text(json.dumps({
        "segments": [{"from": "2026-04-20", "to": "2026-04-25", "workspace": "丽金智地中心 B座"}],
        "next_week": None,
    }, ensure_ascii=False), encoding="utf-8")

    scan_events.PREFS_FILE = prefs_file
    scan_events.workspace_manager.WORKSPACE_FILE = workspace_file

    default_building = scan_events.get_default_building_for_user("ou_self", "2026-04-20")

    assert default_building == "丽金智地中心 B座"


def test_get_default_building_for_user_uses_next_week_when_date_matches(tmp_path):
    prefs_file = tmp_path / "user-preferences.json"
    prefs_file.write_text(json.dumps({"preferences": {"ou_self": {}}}, ensure_ascii=False), encoding="utf-8")

    workspace_file = tmp_path / "weekly-workspace.json"
    workspace_file.write_text(json.dumps({
        "segments": [],
        "next_week": {"week_start": "2026-04-21", "workspace": "紫金数码园4号楼"},
    }, ensure_ascii=False), encoding="utf-8")

    scan_events.PREFS_FILE = prefs_file
    scan_events.workspace_manager.WORKSPACE_FILE = workspace_file

    default_building = scan_events.get_default_building_for_user("ou_self", "2026-04-22")

    assert default_building == "紫金数码园4号楼"


def test_outputs_json_only_when_events_file_is_used(tmp_path):
    events_file = tmp_path / "events.json"
    events_file.write_text(json.dumps([
        build_event(attendees=[{"is_self": True, "status": "accepted"}])
    ], ensure_ascii=False), encoding="utf-8")

    result = subprocess.run(
        [
            sys.executable,
            str(MODULE_PATH),
            "--user",
            "ou_self",
            "--events-file",
            str(events_file),
            "--output",
            "json",
        ],
        capture_output=True,
        text=True,
        check=True,
    )

    payload = json.loads(result.stdout)

    assert payload["needs"] == [
        {
            "event_id": "evt_123",
            "summary": "项目例会",
            "start_time": "2026-04-20T14:00:00+08:00",
            "end_time": "2026-04-20T15:00:00+08:00",
            "organizer": "",
            "reason": "需要补订会议室",
            "verification_status": "confirmed",
        }
    ]
    assert payload["pending_verification"] == []
    assert payload["scan_config"]["user_id"] == "ou_self"



def test_outputs_single_json_document_when_no_events_need_room(tmp_path):
    events_file = tmp_path / "events.json"
    events_file.write_text(json.dumps([
        build_event(attendees=[{"is_self": True, "status": "accepted"}, {"type": "resource", "room_id": "omm_room_1", "status": "accepted"}])
    ], ensure_ascii=False), encoding="utf-8")

    result = subprocess.run(
        [
            sys.executable,
            str(MODULE_PATH),
            "--user",
            "ou_self",
            "--events-file",
            str(events_file),
            "--output",
            "json",
        ],
        capture_output=True,
        text=True,
        check=True,
    )

    payload = json.loads(result.stdout)

    assert payload["needs"] == []
    assert payload["pending_verification"] == []
    assert payload["scan_config"]["user_id"] == "ou_self"



def test_outputs_pending_verification_events_in_json(tmp_path):
    events_file = tmp_path / "events.json"
    events_file.write_text(json.dumps([
        build_event(attendees=[{"is_self": True, "status": "accepted"}, {"type": "resource", "room_id": "omm_room_1", "status": "needs_action"}])
    ], ensure_ascii=False), encoding="utf-8")

    result = subprocess.run(
        [
            sys.executable,
            str(MODULE_PATH),
            "--user",
            "ou_self",
            "--events-file",
            str(events_file),
            "--output",
            "json",
        ],
        capture_output=True,
        text=True,
        check=True,
    )

    payload = json.loads(result.stdout)

    assert payload["needs"] == []
    assert payload["pending_verification"] == [
        {
            "event_id": "evt_123",
            "summary": "项目例会",
            "start_time": "2026-04-20T14:00:00+08:00",
            "end_time": "2026-04-20T15:00:00+08:00",
            "organizer": "",
            "reason": "会议室状态待确认",
            "verification_status": "pending",
        }
    ]


path: tests/test_watch_waitlist.py
content: from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path


MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "watch_waitlist.py"
SPEC = spec_from_file_location("watch_waitlist", MODULE_PATH)
watch_waitlist = module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(watch_waitlist)


BASE_ITEM = {
    "event_id": "evt_123",
    "summary": "项目例会",
    "start": "2026-04-20T14:00:00+08:00",
    "end": "2026-04-20T15:00:00+08:00",
    "building": "丽金智地中心 B座",
    "capacity_gte": 8,
    "attempted_rooms": [],
    "attempts": 1,
    "status": "ready",
    "suggested_room": "F11-15(8)",
    "suggested_room_id": "omm_room_1",
}


def test_apply_verification_result_removes_confirmed_item():
    updated_item, should_remove = watch_waitlist.apply_verification_result(
        BASE_ITEM,
        "confirmed",
        "会议室预订已确认",
        verified_at="2026-05-20T10:00:00+08:00",
    )

    assert should_remove is True
    assert updated_item is None



def test_apply_verification_result_keeps_pending_item_active():
    updated_item, should_remove = watch_waitlist.apply_verification_result(
        BASE_ITEM,
        "pending",
        "会议室状态待确认",
        verified_at="2026-05-20T10:00:00+08:00",
    )

    assert should_remove is False
    assert updated_item is not None
    assert updated_item["status"] == "verification_pending"
    assert updated_item["last_verification_status"] == "pending"
    assert updated_item["last_verification_reason"] == "会议室状态待确认"
    assert updated_item["last_verified_at"] == "2026-05-20T10:00:00+08:00"
    assert updated_item["suggested_room_id"] == "omm_room_1"



def test_apply_verification_result_returns_failed_item_to_waiting():
    updated_item, should_remove = watch_waitlist.apply_verification_result(
        BASE_ITEM,
        "failed",
        "会议室预订被拒绝",
        verified_at="2026-05-20T10:00:00+08:00",
    )

    assert should_remove is False
    assert updated_item is not None
    assert updated_item["status"] == "waiting"
    assert updated_item["last_verification_status"] == "failed"
    assert updated_item["last_verification_reason"] == "会议室预订被拒绝"
    assert updated_item["last_verified_at"] == "2026-05-20T10:00:00+08:00"
    assert updated_item["attempted_rooms"] == ["omm_room_1"]
    assert "suggested_room" not in updated_item
    assert "suggested_room_id" not in updated_item


path: tests/test_workspace_manager.py
content: from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path


MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "workspace_manager.py"
SPEC = spec_from_file_location("workspace_manager", MODULE_PATH)
workspace_manager = module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(workspace_manager)


def test_find_current_workspace_returns_active_segment_for_date():
    data = {
        "segments": [
            {"from": "2026-05-19", "to": "2026-05-21", "workspace": "丽金智地中心 B座"},
            {"from": "2026-05-22", "to": "2026-05-25", "workspace": "紫金数码园4号楼"},
        ],
        "next_week": None,
    }

    resolved = workspace_manager.find_current_workspace(data, "2026-05-20")

    assert resolved == "丽金智地中心 B座"


def test_resolve_workspace_for_date_uses_next_week_only_for_matching_week():
    data = {
        "segments": [],
        "next_week": {
            "week_start": "2026-05-25",
            "workspace": "紫金数码园4号楼",
        },
    }

    current_week = workspace_manager.resolve_workspace_for_date(data, "2026-05-20")
    next_week = workspace_manager.resolve_workspace_for_date(data, "2026-05-26")

    assert current_week is None
    assert next_week == "紫金数码园4号楼"


def test_merge_segment_trims_overlapping_ranges():
    data = {
        "segments": [
            {"from": "2026-05-19", "to": "2026-05-25", "workspace": "丽金智地中心 B座"},
        ],
        "next_week": None,
    }

    merged = workspace_manager.merge_segment(data, "紫金数码园4号楼", "2026-05-21", "2026-05-22")

    assert merged["segments"] == [
        {"from": "2026-05-19", "to": "2026-05-20", "workspace": "丽金智地中心 B座"},
        {
            "from": "2026-05-21",
            "to": "2026-05-22",
            "workspace": "紫金数码园4号楼",
            "set_at": merged["segments"][1]["set_at"],
            "set_by": "manual",
        },
        {"from": "2026-05-23", "to": "2026-05-25", "workspace": "丽金智地中心 B座"},
    ]


def test_recommend_workspace_uses_recent_selection_history():
    preferences = {
        "preferences": {
            "ou_a": {
                "selection_history": [
                    {"building": "丽金智地中心 B座"},
                    {"building": "丽金智地中心 B座"},
                    {"building": "紫金数码园4号楼"},
                ]
            },
            "ou_b": {
                "selection_history": [
                    {"building": "丽金智地中心 B座"},
                ]
            },
        }
    }

    recommendation, ranking = workspace_manager.recommend_workspace(preferences)

    assert recommendation == "丽金智地中心 B座"
    assert ranking[0] == ("丽金智地中心 B座", 3)


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)