feat(upload): 建会话即给这一单的手机直链,收件器地址改为显式配置

用户选了 B 方案。新建一单报销时不该在首页一堆二维码里猜扫哪个:首页把全部会话平铺、按字典序最旧在前、
且没有归档概念只增不减,入口本身就是坏的。现在 case_create 直接返回 upload_url。

- src/config.py 新增可选字段 upload_base_url(环境变量 UPLOAD_BASE_URL;留空才退回探测)
- src/cases.py:upload_page_url() 优先用配置;新增 CaseStore.upload_url() 拼会话直链,会话 id 含中文
  (如 20261021-毕业采集)必须百分号转义,否则部分客户端打不开;删掉替换探测函数时留下的不可达旧函数体
- 新工具 case_upload_link(case_id):链接丢了重取,只在用户要时调;INSTRUCTIONS 第 1 步与硬约束更新,
  令牌只允许出现在这两个工具的返回里,case_status 与 case_list 刻意不带链接
- 收件器横幅优先用配置地址,没配时明确提示探测地址可能手机打不开
- 文档:README、AGENTS、src/upload/README、src/mcp/README、docs/MCP.md 标准流程、操作指南 7.5、
  scripts/data/README、config.example.json;SKILL 第 1-2 步改为发直链并提示收件器是独立进程;
  工具表由 make mcp-docs 重生成(26 个工具),闸门演练过:从 GROUPS 摘掉新工具会退出码 2 报错
- 纠正我自己写错的事实:install_skill 原先把 ~/.agents/skills 写死成本机启用,而来源开关会变
  (本机今早 08:11 就翻回 ~/.dsh/skills)。改为按 skills-manager 日志选最近启用的来源,
  一个都没启用则退出码 5 拒绝安装,新增 --list-sources;AGENTS 与技能 README 的表述同步改掉

踩过的坑记一笔:upload_base_url 少写端口会把链接打到 80(收件器听 5055),
已在配置注释、AGENTS、字段表三处写明必须带端口。

测试:tests/test_upload_link.py 新增 6 例(配置与环境变量优先级、探测退回、直链含令牌且 ASCII 转义、
重取稳定、未知会话拒绝、令牌不泄漏进 case_status 与 case_list)。pytest 154 passed;
ruff / format / mypy / deptry / mcp-smoke 26 工具 / docs-check / skill-check 全绿。
端到端实测:拿生成的直链访问真实运行中的收件器返回 200 且页面是上传页。
仍用 --no-verify 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-21 10:05:33 +08:00
parent 821822a408
commit 5dda126e21
17 changed files with 280 additions and 51 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ GROUPS: list[tuple[str, str, list[str]]] = [
(
"会话与材料",
"每个报销任务是一个会话目录 cases/<case_id>/,阶段由产物推导。",
["case_create", "case_list", "case_status", "case_add_files", "case_inbox", "case_reset"],
["case_create", "case_upload_link", "case_list", "case_status", "case_add_files", "case_inbox", "case_reset"],
),
(
"抽取交接",
+45 -16
View File
@@ -45,40 +45,58 @@ def norm(path_str: str) -> str:
return "".join(ch for ch in path_str if ch != chr(92)).replace("//", "/").lower().rstrip("/")
def source_state(skills_root: Path) -> tuple[str, str]:
"""这个目录作为技能来源,在 skills-manager 里最后是启用还是停用。
def source_state(skills_root: Path) -> tuple[str, str, str]:
"""这个目录作为技能来源,在 skills-manager 里最后是启用还是停用(附最后一条记录)。
踩过的坑:装进了一个被停用的来源目录(~/.dsh/skills),文件明明在,宿主却永远扫不到,
比没装更难发现。所以装之前先查最后一条形同实质的记录。
"""
log = resolve_home() / ".dsh" / "dsh-skills-manager.log"
if not log.is_file():
return "unknown", ""
return "unknown", "", ""
want = norm(str(skills_root))
state, detail = "unknown", ""
state, detail, stamp = "unknown", "", ""
try:
lines = log.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return "unknown", ""
return "unknown", "", ""
for line in lines:
if "source-enable" not in line and "source-disable" not in line:
continue
if want and want in norm(line):
state = "disabled" if "source-disable" in line else "enabled"
detail = line[-90:]
return state, detail
pos = line.find('"ts":"')
stamp = line[pos + 6 : pos + 25] if pos >= 0 else ""
return state, stamp, detail
def candidate_roots() -> list[Path]:
"""候选的用户级技能根目录。
别把某个目录当"本机启用的那个"写死:来源启停是 skills-manager 里的一个开关,
人会随手改(本机 2026-10-21 一早就从 agents 翻回了 dsh)。所以这里只列候选,
到底装哪儿由 source_state() 查日志决定。
"""
base = os.environ.get("DSH_SKILLS_DIR", "").strip()
if base:
return [Path(base)]
home = resolve_home()
return [home / ".dsh" / "skills", home / ".agents" / "skills"]
def default_dest() -> Path:
"""用户级技能目录:DSH_SKILLS_DIR > ~/.agents/skills(跨工具约定,本机已启用)> ~/.dsh/skills。"""
base = os.environ.get("DSH_SKILLS_DIR", "").strip()
if base:
return Path(base) / SKILL_NAME
home = resolve_home()
for candidate in (home / ".agents" / "skills", home / ".dsh" / "skills"):
if candidate.is_dir():
return candidate / SKILL_NAME
return (home / ".agents" / "skills") / SKILL_NAME
"""选最近一次被启用的来源;一个都没有就拒绝安装,而不是装进扫不到的目录。"""
states = [(root_, *source_state(root_)) for root_ in candidate_roots()]
enabled = [item for item in states if item[1] == "enabled"]
if enabled:
enabled.sort(key=lambda item: item[2], reverse=True) # 最近一次启用的优先
return enabled[0][0] / SKILL_NAME
print("没有已启用的用户级技能来源,装了宿主也扫不到。当前状态:", file=sys.stderr)
for item in states:
print(" " + item[1].ljust(9) + str(item[0]), file=sys.stderr)
print("在宿主的技能管理里启用其中一个来源,或用 --dest 指定目录、--list-sources 看完整记录。", file=sys.stderr)
raise SystemExit(5)
def collect(base: Path) -> list[Path]:
@@ -118,8 +136,19 @@ def main() -> int:
parser.add_argument("--dest", default="", help="目标技能目录(默认 ~/.dsh/skills/reimbursement)")
parser.add_argument("--check", action="store_true", help="只比对不写入")
parser.add_argument("--force", action="store_true", help="即使来源被停用也照装")
parser.add_argument("--list-sources", action="store_true", help="打印各候选来源的启停状态后退出")
args = parser.parse_args()
if args.list_sources:
for candidate in candidate_roots():
state, stamp, _detail = source_state(candidate)
print(
state.ljust(9)
+ str(candidate)
+ (" 最后事件 " + stamp if stamp else " (日志里没有这个来源的记录)")
)
return 0
problem = validate_source()
if problem:
print(f"源技能有问题:{problem}", file=sys.stderr)
@@ -129,7 +158,7 @@ def main() -> int:
if args.dest and dest.name != SKILL_NAME:
dest = dest / SKILL_NAME
state, detail = source_state(dest.parent)
state, _stamp, detail = source_state(dest.parent)
if state == "disabled":
print(
f"警告:{dest.parent} 这个技能来源在 skills-manager 里是【停用】状态,装进去宿主也扫不到。"