Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

209 lines
7.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Interactive taxonomy pickers for ``skill publish`` / ``skill update``.
Terminal prompts that mirror EduHub's web upload form: a required single-select
``track`` plus the optional multi-select facets (language is single-select with
a default). Selections can be pre-filled — from SKILL.md frontmatter on publish,
or from the current skill's labels on update — so the common path is just
pressing Enter through the prompts.
Kept presentation-only and dependency-light (rich console + ``typer.prompt``)
so the publish/update commands stay readable and these are easy to unit-test.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import typer
from deeptutor.services.skill.taxonomy import (
AUDIENCE_OPTIONS,
DOMAIN_TREE,
FORM_OPTIONS,
LANGUAGE_OPTIONS,
STAGE_OPTIONS,
TRACK_OPTIONS,
Option,
is_valid_track,
)
from .common import console
def _render_options(options: Sequence[Option], selected: set[str], locale: str) -> None:
for i, opt in enumerate(options, 1):
mark = "[green]●[/]" if opt.value in selected else "[dim]○[/]"
console.print(f" {mark} [bold]{i}[/]. {opt.label(locale)} [dim]{opt.value}[/]")
def _parse_indices(raw: str, count: int) -> list[int] | None:
"""Parse ``"1, 3 4"`` into 0-based indices; None if any token is invalid."""
out: list[int] = []
for tok in raw.replace(",", " ").split():
if not tok.isdigit():
return None
n = int(tok)
if not (1 <= n <= count):
return None
if n - 1 not in out:
out.append(n - 1)
return out
def select_one(
options: Sequence[Option],
title: str,
*,
hint: str = "",
default: str | None = None,
locale: str = "zh",
) -> str:
"""Prompt for exactly one value. Enter accepts ``default`` when given."""
console.print(f"\n[bold]{title}[/]" + (f" [dim]{hint}[/]" if hint else ""))
_render_options(options, {default} if default else set(), locale)
default_idx = next((i + 1 for i, o in enumerate(options) if o.value == default), None)
suffix = f"(回车=默认 {default_idx}" if default_idx else ""
while True:
raw = typer.prompt(f" 输入编号{suffix}", default="", show_default=False).strip()
if not raw and default_idx:
return options[default_idx - 1].value
idx = _parse_indices(raw, len(options))
if idx and len(idx) == 1:
return options[idx[0]].value
console.print(" [red]请输入一个有效编号。[/]")
def select_many(
options: Sequence[Option],
title: str,
*,
hint: str = "可多选,逗号分隔;回车=保留当前;输入 - 清空",
preselected: Sequence[str] = (),
locale: str = "zh",
) -> list[str]:
"""Prompt for zero or more values. Enter keeps ``preselected``; ``-`` clears."""
current = [v for v in preselected if any(o.value == v for o in options)]
console.print(f"\n[bold]{title}[/] [dim]{hint}[/]")
_render_options(options, set(current), locale)
while True:
raw = typer.prompt(" 输入编号", default="", show_default=False).strip()
if not raw:
return list(current)
if raw == "-":
return []
idx = _parse_indices(raw, len(options))
if idx is not None:
return [options[i].value for i in idx]
console.print(" [red]请输入有效编号(逗号分隔),或回车/-。[/]")
def select_domains(
*,
preselected: Sequence[str] = (),
locale: str = "zh",
) -> list[str]:
"""Two-level domain picker: pick top-level groups, then optional children.
A group with chosen children contributes those children; a group with none
contributes the group itself — matching the web form's ``resolveDomains``.
"""
pre_roots = {v.split(".")[0] for v in preselected}
root_options = [Option(n.value, n.zh, n.en) for n in DOMAIN_TREE]
roots = select_many(
root_options,
"领域 (domains) — 可多选;选中后可再挑细分",
preselected=[r for r in pre_roots if any(o.value == r for o in root_options)],
locale=locale,
)
result: list[str] = []
for root in roots:
node = next((n for n in DOMAIN_TREE if n.value == root), None)
if node is None or not node.children:
result.append(root)
continue
pre_children = [v for v in preselected if v.startswith(f"{root}.")]
children = select_many(
list(node.children),
f" 「{node.label(locale)}」的细分 — 可多选;回车=用「{node.label(locale)}」整体",
hint="可多选,逗号分隔;回车=保留当前/整体;输入 - 清空",
preselected=pre_children,
locale=locale,
)
result.extend(children if children else [root])
return result
def _as_list(defaults: dict[str, Any], key: str) -> list[str]:
value = defaults.get(key)
if isinstance(value, list):
return [str(x).strip() for x in value if str(x).strip()]
if isinstance(value, str) and value.strip():
return [s.strip() for s in value.split(",") if s.strip()]
return []
def collect_classification(defaults: dict[str, Any], *, locale: str = "zh") -> dict[str, str]:
"""Walk the full tagging flow (track + facets), pre-filled from ``defaults``.
Shared by ``skill publish`` (defaults = SKILL.md frontmatter) and
``skill update`` (defaults = the current skill's labels). Returns the
comma-joined ``overrides`` dict ``publish_to_hub`` expects.
"""
track_default = str(defaults.get("track") or "")
track = select_one(
TRACK_OPTIONS,
"主类目 track — 必选",
hint="这个技能服务什么场景?",
default=track_default if is_valid_track(track_default) else None,
locale=locale,
)
lang_default = str(defaults.get("language") or "zh")
language = select_one(
LANGUAGE_OPTIONS,
"语言 language — 必选",
hint="技能与用户交流所用的语言",
default=lang_default if any(o.value == lang_default for o in LANGUAGE_OPTIONS) else "zh",
locale=locale,
)
domains = select_domains(preselected=_as_list(defaults, "domains"), locale=locale)
stages = select_many(
STAGE_OPTIONS,
"学段 stages — 可选;空=全学段通用",
preselected=_as_list(defaults, "stages"),
locale=locale,
)
forms = select_many(
FORM_OPTIONS,
"形式 forms — 可选;技能如何与用户交互",
preselected=_as_list(defaults, "forms"),
locale=locale,
)
audiences = select_many(
AUDIENCE_OPTIONS,
"受众 audiences — 可选;空=面向学习者",
preselected=_as_list(defaults, "audiences"),
locale=locale,
)
tags_default = ",".join(_as_list(defaults, "tags"))
tags = typer.prompt(
"\n标签 tags — 比领域更细,逗号分隔,可空",
default=tags_default,
show_default=bool(tags_default),
).strip()
return {
"track": track,
"language": language,
"domains": ",".join(domains),
"stages": ",".join(stages),
"forms": ",".join(forms),
"audiences": ",".join(audiences),
"tags": tags,
}
__all__ = ["collect_classification", "select_domains", "select_many", "select_one"]