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
209 lines
7.3 KiB
Python
209 lines
7.3 KiB
Python
"""
|
||
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"]
|