logion-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +2 -0
- cli/_config.py +51 -0
- cli/_confirm.py +16 -0
- cli/_context.py +17 -0
- cli/_course_bundle.py +46 -0
- cli/_course_capabilities.py +580 -0
- cli/_credentials.py +104 -0
- cli/_errors.py +82 -0
- cli/_first_run.py +90 -0
- cli/_harness/__init__.py +68 -0
- cli/_harness/base.py +106 -0
- cli/_harness/claude_code.py +168 -0
- cli/_harness/codex.py +79 -0
- cli/_harness/custom.py +55 -0
- cli/_harness/hermes.py +93 -0
- cli/_harness/opencode.py +255 -0
- cli/_local_state.py +1053 -0
- cli/_options.py +36 -0
- cli/_output.py +47 -0
- cli/_parser.py +73 -0
- cli/_recall_calibration.py +90 -0
- cli/_recall_ranker.py +74 -0
- cli/_taxonomy.py +120 -0
- cli/_update_policy.py +152 -0
- cli/_utils.py +16 -0
- cli/_version.py +26 -0
- cli/commands/__init__.py +2 -0
- cli/commands/admin.py +535 -0
- cli/commands/bounties.py +490 -0
- cli/commands/course_reviews/__init__.py +6 -0
- cli/commands/course_reviews/_download_handler.py +104 -0
- cli/commands/course_reviews/_render.py +129 -0
- cli/commands/course_reviews/handlers.py +197 -0
- cli/commands/course_reviews/parser.py +93 -0
- cli/commands/courses/__init__.py +6 -0
- cli/commands/courses/_capability_render.py +183 -0
- cli/commands/courses/_cmd_help.py +18 -0
- cli/commands/courses/_purchase.py +76 -0
- cli/commands/courses/_review_helpers.py +93 -0
- cli/commands/courses/_taxonomy_data.py +173 -0
- cli/commands/courses/_upload_bundle_validation.py +28 -0
- cli/commands/courses/_uploads_push.py +243 -0
- cli/commands/courses/capabilities.py +250 -0
- cli/commands/courses/capability_frontmatter.py +150 -0
- cli/commands/courses/handlers.py +50 -0
- cli/commands/courses/mutations.py +217 -0
- cli/commands/courses/parser.py +66 -0
- cli/commands/courses/parser_capabilities.py +95 -0
- cli/commands/courses/parser_sections.py +239 -0
- cli/commands/courses/parser_uploads.py +84 -0
- cli/commands/courses/parser_utils.py +65 -0
- cli/commands/courses/publication.py +60 -0
- cli/commands/courses/report_usage.py +131 -0
- cli/commands/courses/reviews.py +237 -0
- cli/commands/courses/taxonomy_handler.py +61 -0
- cli/commands/courses/taxonomy_suggest.py +197 -0
- cli/commands/courses/uploads.py +142 -0
- cli/commands/courses/versions.py +65 -0
- cli/commands/credits/__init__.py +6 -0
- cli/commands/credits/_helpers.py +153 -0
- cli/commands/credits/handlers.py +218 -0
- cli/commands/credits/parser.py +115 -0
- cli/commands/docs/__init__.py +6 -0
- cli/commands/docs/handlers.py +137 -0
- cli/commands/docs/parser.py +27 -0
- cli/commands/health/__init__.py +6 -0
- cli/commands/health/handlers.py +26 -0
- cli/commands/health/parser.py +20 -0
- cli/commands/identity/__init__.py +6 -0
- cli/commands/identity/_autopost.py +97 -0
- cli/commands/identity/_closing_copy.py +89 -0
- cli/commands/identity/_companion.py +232 -0
- cli/commands/identity/_companion_source.py +135 -0
- cli/commands/identity/_harness_select.py +85 -0
- cli/commands/identity/_onboarding_helpers.py +168 -0
- cli/commands/identity/handlers.py +173 -0
- cli/commands/identity/onboarding.py +246 -0
- cli/commands/identity/parser.py +72 -0
- cli/commands/listings/__init__.py +6 -0
- cli/commands/listings/handlers.py +135 -0
- cli/commands/listings/parser.py +57 -0
- cli/commands/notifications/__init__.py +6 -0
- cli/commands/notifications/handlers.py +120 -0
- cli/commands/notifications/parser.py +49 -0
- cli/commands/payments/__init__.py +6 -0
- cli/commands/payments/_orders_helpers.py +114 -0
- cli/commands/payments/handlers.py +138 -0
- cli/commands/payments/parser.py +97 -0
- cli/commands/recall/__init__.py +7 -0
- cli/commands/recall/handlers.py +87 -0
- cli/commands/recall/parser.py +70 -0
- cli/commands/referrals/__init__.py +6 -0
- cli/commands/referrals/_helpers.py +63 -0
- cli/commands/referrals/handlers.py +100 -0
- cli/commands/referrals/parser.py +65 -0
- cli/commands/reports/__init__.py +6 -0
- cli/commands/reports/handlers.py +57 -0
- cli/commands/reports/parser.py +52 -0
- cli/commands/skills/__init__.py +7 -0
- cli/commands/skills/_agent_symlink.py +161 -0
- cli/commands/skills/_finalize.py +112 -0
- cli/commands/skills/_inspect_handler.py +218 -0
- cli/commands/skills/_install_helpers.py +186 -0
- cli/commands/skills/_query_handlers.py +83 -0
- cli/commands/skills/_search_handler.py +136 -0
- cli/commands/skills/_update_handler.py +110 -0
- cli/commands/skills/_verify_handler.py +109 -0
- cli/commands/skills/handlers.py +202 -0
- cli/commands/skills/parser.py +154 -0
- cli/commands/workspace.py +406 -0
- cli/docs/README.md +5 -0
- cli/docs/__init__.py +1 -0
- cli/docs/bounties-and-referrals.md +18 -0
- cli/docs/concepts.md +47 -0
- cli/docs/creating-courses.md +25 -0
- cli/docs/credits-and-purchases.md +30 -0
- cli/docs/credits-terms.md +23 -0
- cli/docs/getting-started.md +95 -0
- cli/docs/marketplace-loop.md +108 -0
- cli/docs/privacy.md +30 -0
- cli/docs/referral-terms.md +24 -0
- cli/docs/reviews.md +47 -0
- cli/docs/safety.md +28 -0
- cli/docs/terms.md +54 -0
- cli/main.py +84 -0
- cli/templates/__init__.py +2 -0
- cli/templates/course_capabilities.template.yaml +189 -0
- cli/templates/course_license_apache-2.0.template.txt +30 -0
- cli/templates/course_license_logion-standard-course-v1.template.txt +49 -0
- cli/templates/course_license_mit.template.txt +21 -0
- logion_cli-0.1.0.dist-info/METADATA +49 -0
- logion_cli-0.1.0.dist-info/RECORD +135 -0
- logion_cli-0.1.0.dist-info/WHEEL +4 -0
- logion_cli-0.1.0.dist-info/entry_points.txt +4 -0
- logion_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Agent-driven review convenience command."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
|
|
8
|
+
from cli._config import resolve_config_from_args
|
|
9
|
+
from cli._context import make_client
|
|
10
|
+
from cli._errors import handle_error, validate_uuid_id
|
|
11
|
+
from cli._output import emit, emit_json
|
|
12
|
+
from cli._utils import only_not_none
|
|
13
|
+
|
|
14
|
+
from .reviews import _validate_review_scores
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def handle_report_usage(args: argparse.Namespace) -> int:
|
|
18
|
+
"""Execute the courses report-usage command.
|
|
19
|
+
|
|
20
|
+
Thin convenience wrapper over ``reviews upsert`` for the agent-driven
|
|
21
|
+
review loop. Same guardrails (entitlement-required, self-review-blocked)
|
|
22
|
+
apply because the call is forwarded to the same API endpoint.
|
|
23
|
+
"""
|
|
24
|
+
bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
|
|
25
|
+
if bad_id is not None:
|
|
26
|
+
return bad_id
|
|
27
|
+
bad_id = validate_uuid_id(args.version_id, "VERSION_ID")
|
|
28
|
+
if bad_id is not None:
|
|
29
|
+
return bad_id
|
|
30
|
+
review_scores_error = _validate_review_scores(args)
|
|
31
|
+
if review_scores_error is not None:
|
|
32
|
+
return review_scores_error
|
|
33
|
+
|
|
34
|
+
config = resolve_config_from_args(args)
|
|
35
|
+
client = make_client(config)
|
|
36
|
+
try:
|
|
37
|
+
kwargs = only_not_none(
|
|
38
|
+
{
|
|
39
|
+
"course_id": args.course_id,
|
|
40
|
+
"version_id": args.version_id,
|
|
41
|
+
"rating": args.rating,
|
|
42
|
+
},
|
|
43
|
+
body=args.body,
|
|
44
|
+
completed_task=args.completed_task,
|
|
45
|
+
reliability=args.reliability,
|
|
46
|
+
usefulness=args.usefulness,
|
|
47
|
+
tool_safety=args.tool_safety,
|
|
48
|
+
token_efficiency=args.token_efficiency,
|
|
49
|
+
)
|
|
50
|
+
result = client.v1.courses.review_version(**kwargs)
|
|
51
|
+
from cli.commands.courses._review_helpers import (
|
|
52
|
+
data_or_model_dump,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
data = data_or_model_dump(result)
|
|
56
|
+
if config.json_output:
|
|
57
|
+
emit_json("logion.courses.report-usage", data)
|
|
58
|
+
else:
|
|
59
|
+
emit(result, json_output=False)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
return handle_error(exc)
|
|
62
|
+
else:
|
|
63
|
+
return 0
|
|
64
|
+
finally:
|
|
65
|
+
client.close()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def register_report_usage(
|
|
69
|
+
subparsers: argparse._SubParsersAction,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Register the ``courses report-usage`` subcommand."""
|
|
72
|
+
from cli._options import COMMON_PARSER
|
|
73
|
+
|
|
74
|
+
parser = subparsers.add_parser(
|
|
75
|
+
"report-usage",
|
|
76
|
+
help="File a usage review after completing a course-driven task",
|
|
77
|
+
parents=[COMMON_PARSER],
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument("course_id", metavar="COURSE_ID")
|
|
80
|
+
parser.add_argument("version_id", metavar="VERSION_ID")
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--rating",
|
|
83
|
+
type=int,
|
|
84
|
+
required=True,
|
|
85
|
+
help="Overall rating 1-5 (required).",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--body",
|
|
89
|
+
default=None,
|
|
90
|
+
help="Short narrative: what worked, what didn't.",
|
|
91
|
+
)
|
|
92
|
+
completed = parser.add_mutually_exclusive_group()
|
|
93
|
+
completed.add_argument(
|
|
94
|
+
"--completed-task",
|
|
95
|
+
action="store_true",
|
|
96
|
+
default=None,
|
|
97
|
+
dest="completed_task",
|
|
98
|
+
help="The task finished successfully.",
|
|
99
|
+
)
|
|
100
|
+
completed.add_argument(
|
|
101
|
+
"--not-completed-task",
|
|
102
|
+
action="store_false",
|
|
103
|
+
default=None,
|
|
104
|
+
dest="completed_task",
|
|
105
|
+
help="The task did not finish.",
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--reliability",
|
|
109
|
+
type=float,
|
|
110
|
+
default=None,
|
|
111
|
+
help="Did the course work without surprises? 0.0-5.0.",
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--usefulness",
|
|
115
|
+
type=float,
|
|
116
|
+
default=None,
|
|
117
|
+
help="Did the course content help with the task? 0.0-5.0.",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--tool-safety",
|
|
121
|
+
type=float,
|
|
122
|
+
default=None,
|
|
123
|
+
help="Did it stay within declared capabilities? 0.0-5.0.",
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--token-efficiency",
|
|
127
|
+
type=float,
|
|
128
|
+
default=None,
|
|
129
|
+
help="Subjective token cost impression. 0.0-5.0.",
|
|
130
|
+
)
|
|
131
|
+
parser.set_defaults(handler=handle_report_usage)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Review handlers for courses commands."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from cli._config import resolve_config_from_args
|
|
10
|
+
from cli._context import make_client
|
|
11
|
+
from cli._errors import (
|
|
12
|
+
handle_error,
|
|
13
|
+
print_err,
|
|
14
|
+
validate_uuid_id,
|
|
15
|
+
)
|
|
16
|
+
from cli._output import emit, emit_json, to_data
|
|
17
|
+
from cli._utils import only_not_none
|
|
18
|
+
from cli.commands.courses._capability_render import (
|
|
19
|
+
append_capability_feedback_lines,
|
|
20
|
+
)
|
|
21
|
+
from cli.commands.courses._review_helpers import (
|
|
22
|
+
collect_reviews,
|
|
23
|
+
compact_review,
|
|
24
|
+
compute_summary,
|
|
25
|
+
data_or_model_dump,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
REVIEW_SCORE_FIELDS = [
|
|
29
|
+
("reliability", "--reliability"),
|
|
30
|
+
("usefulness", "--usefulness"),
|
|
31
|
+
("tool_safety", "--tool-safety"),
|
|
32
|
+
("token_efficiency", "--token-efficiency"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_DEFAULT_LIST_LIMIT = 5
|
|
36
|
+
_MAX_LIST_LIMIT = 50
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_review_scores(args: argparse.Namespace) -> int | None:
|
|
40
|
+
if not (1 <= args.rating <= 5):
|
|
41
|
+
print_err("Error: --rating must be between 1 and 5.")
|
|
42
|
+
return 2
|
|
43
|
+
for attr, label in REVIEW_SCORE_FIELDS:
|
|
44
|
+
value = getattr(args, attr)
|
|
45
|
+
if value is not None and not (0.0 <= value <= 5.0):
|
|
46
|
+
print_err(f"Error: {label} must be between 0.0 and 5.0.")
|
|
47
|
+
return 2
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def handle_reviews_list(args: argparse.Namespace) -> int:
|
|
52
|
+
"""Execute the courses reviews list command."""
|
|
53
|
+
bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
|
|
54
|
+
if bad_id is not None:
|
|
55
|
+
return bad_id
|
|
56
|
+
config = resolve_config_from_args(args)
|
|
57
|
+
client = make_client(config)
|
|
58
|
+
try:
|
|
59
|
+
limit = min(
|
|
60
|
+
max(args.limit or _DEFAULT_LIST_LIMIT, 1),
|
|
61
|
+
_MAX_LIST_LIMIT,
|
|
62
|
+
)
|
|
63
|
+
kwargs = only_not_none(
|
|
64
|
+
{"course_id": args.course_id},
|
|
65
|
+
version=args.version,
|
|
66
|
+
limit=limit,
|
|
67
|
+
cursor=args.cursor,
|
|
68
|
+
)
|
|
69
|
+
result = client.v1.courses.list_reviews(**kwargs)
|
|
70
|
+
if config.json_output:
|
|
71
|
+
data = data_or_model_dump(result)
|
|
72
|
+
reviews = [
|
|
73
|
+
compact_review(review)
|
|
74
|
+
for review in data.get("reviews", [])
|
|
75
|
+
if isinstance(review, dict)
|
|
76
|
+
]
|
|
77
|
+
emit_json(
|
|
78
|
+
"logion.courses.reviews.list",
|
|
79
|
+
{
|
|
80
|
+
"items": reviews,
|
|
81
|
+
"limit": kwargs["limit"],
|
|
82
|
+
"next_cursor": data.get("next_cursor"),
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
emit(result, json_output=False)
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
return handle_error(exc)
|
|
89
|
+
else:
|
|
90
|
+
return 0
|
|
91
|
+
finally:
|
|
92
|
+
client.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def handle_reviews_mine(args: argparse.Namespace) -> int:
|
|
96
|
+
"""Execute the courses reviews mine command."""
|
|
97
|
+
bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
|
|
98
|
+
if bad_id is not None:
|
|
99
|
+
return bad_id
|
|
100
|
+
if args.version_id is not None:
|
|
101
|
+
bad_id = validate_uuid_id(args.version_id, "--version-id")
|
|
102
|
+
if bad_id is not None:
|
|
103
|
+
return bad_id
|
|
104
|
+
config = resolve_config_from_args(args)
|
|
105
|
+
client = make_client(config)
|
|
106
|
+
try:
|
|
107
|
+
kwargs = only_not_none(
|
|
108
|
+
{"course_id": args.course_id},
|
|
109
|
+
version_id=args.version_id,
|
|
110
|
+
)
|
|
111
|
+
result = client.v1.courses.get_my_review(**kwargs)
|
|
112
|
+
if config.json_output:
|
|
113
|
+
data = data_or_model_dump(result)
|
|
114
|
+
emit_json("logion.courses.reviews.mine", data)
|
|
115
|
+
else:
|
|
116
|
+
emit(result, json_output=False)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
return handle_error(exc)
|
|
119
|
+
else:
|
|
120
|
+
return 0
|
|
121
|
+
finally:
|
|
122
|
+
client.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def handle_reviews_upsert(args: argparse.Namespace) -> int:
|
|
126
|
+
"""Execute the courses reviews upsert command."""
|
|
127
|
+
bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
|
|
128
|
+
if bad_id is not None:
|
|
129
|
+
return bad_id
|
|
130
|
+
bad_id = validate_uuid_id(args.version_id, "VERSION_ID")
|
|
131
|
+
if bad_id is not None:
|
|
132
|
+
return bad_id
|
|
133
|
+
review_scores_error = _validate_review_scores(args)
|
|
134
|
+
if review_scores_error is not None:
|
|
135
|
+
return review_scores_error
|
|
136
|
+
config = resolve_config_from_args(args)
|
|
137
|
+
client = make_client(config)
|
|
138
|
+
try:
|
|
139
|
+
kwargs = only_not_none(
|
|
140
|
+
{
|
|
141
|
+
"course_id": args.course_id,
|
|
142
|
+
"version_id": args.version_id,
|
|
143
|
+
"rating": args.rating,
|
|
144
|
+
},
|
|
145
|
+
body=args.body,
|
|
146
|
+
completed_task=args.completed_task,
|
|
147
|
+
reliability=args.reliability,
|
|
148
|
+
usefulness=args.usefulness,
|
|
149
|
+
tool_safety=args.tool_safety,
|
|
150
|
+
token_efficiency=args.token_efficiency,
|
|
151
|
+
)
|
|
152
|
+
result = client.v1.courses.review_version(**kwargs)
|
|
153
|
+
if config.json_output:
|
|
154
|
+
data = data_or_model_dump(result)
|
|
155
|
+
emit_json("logion.courses.reviews.upsert", data)
|
|
156
|
+
else:
|
|
157
|
+
emit(result, json_output=False)
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
return handle_error(exc)
|
|
160
|
+
else:
|
|
161
|
+
return 0
|
|
162
|
+
finally:
|
|
163
|
+
client.close()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def handle_reviews_summary(args: argparse.Namespace) -> int:
|
|
167
|
+
"""Compute aggregate review statistics for a course."""
|
|
168
|
+
bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
|
|
169
|
+
if bad_id is not None:
|
|
170
|
+
return bad_id
|
|
171
|
+
config = resolve_config_from_args(args)
|
|
172
|
+
client = make_client(config)
|
|
173
|
+
try:
|
|
174
|
+
limit = min(
|
|
175
|
+
max(args.limit or _DEFAULT_LIST_LIMIT, 1),
|
|
176
|
+
_MAX_LIST_LIMIT,
|
|
177
|
+
)
|
|
178
|
+
all_reviews = collect_reviews(
|
|
179
|
+
client,
|
|
180
|
+
args.course_id,
|
|
181
|
+
version=getattr(args, "version", None),
|
|
182
|
+
limit=limit,
|
|
183
|
+
)
|
|
184
|
+
summary = compute_summary(args.course_id, all_reviews)
|
|
185
|
+
if config.json_output:
|
|
186
|
+
emit_json("logion.courses.reviews.summary", summary)
|
|
187
|
+
else:
|
|
188
|
+
lines: list[str] = [
|
|
189
|
+
f"course_id: {summary['course_id']}",
|
|
190
|
+
f"review_count: {summary['review_count']}",
|
|
191
|
+
f"rating_avg: {summary['rating_avg']}",
|
|
192
|
+
f"rating_histogram: {summary['rating_histogram']}",
|
|
193
|
+
]
|
|
194
|
+
sys.stdout.write("\n".join(lines))
|
|
195
|
+
sys.stdout.write("\n")
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
return handle_error(exc)
|
|
198
|
+
else:
|
|
199
|
+
return 0
|
|
200
|
+
finally:
|
|
201
|
+
client.close()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def handle_feedback(args: argparse.Namespace) -> int:
|
|
205
|
+
"""Execute the courses feedback command."""
|
|
206
|
+
bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
|
|
207
|
+
if bad_id is not None:
|
|
208
|
+
return bad_id
|
|
209
|
+
config = resolve_config_from_args(args)
|
|
210
|
+
client = make_client(config)
|
|
211
|
+
try:
|
|
212
|
+
result = client.v1.courses.get_review_feedback(
|
|
213
|
+
course_id=args.course_id,
|
|
214
|
+
)
|
|
215
|
+
if config.json_output:
|
|
216
|
+
data = data_or_model_dump(result)
|
|
217
|
+
emit_json("logion.courses.feedback", data)
|
|
218
|
+
else:
|
|
219
|
+
data = to_data(result)
|
|
220
|
+
lines: list[str] = []
|
|
221
|
+
if data.get("summary"):
|
|
222
|
+
lines.append(f"summary: {data['summary']}")
|
|
223
|
+
if data.get("findings"):
|
|
224
|
+
lines.append("findings:")
|
|
225
|
+
for f in data["findings"]:
|
|
226
|
+
lines.append(f" - {f}")
|
|
227
|
+
append_capability_feedback_lines(lines, data)
|
|
228
|
+
if not lines:
|
|
229
|
+
lines.append("No feedback available.")
|
|
230
|
+
sys.stdout.write("\n".join(lines))
|
|
231
|
+
sys.stdout.write("\n")
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
return handle_error(exc)
|
|
234
|
+
else:
|
|
235
|
+
return 0
|
|
236
|
+
finally:
|
|
237
|
+
client.close()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Handler and parser registration for ``courses taxonomy`` commands."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from cli._config import resolve_config_from_args
|
|
10
|
+
from cli._options import COMMON_PARSER
|
|
11
|
+
from cli._output import emit_json
|
|
12
|
+
|
|
13
|
+
from .taxonomy_suggest import suggest_taxonomy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def handle_taxonomy_suggest(args: argparse.Namespace) -> int:
|
|
17
|
+
"""Produce deterministic category/tag suggestions for a local bundle."""
|
|
18
|
+
config = resolve_config_from_args(args)
|
|
19
|
+
bundle_dir: Path = args.bundle_dir
|
|
20
|
+
result = suggest_taxonomy(bundle_dir)
|
|
21
|
+
if config.json_output:
|
|
22
|
+
emit_json("logion.courses.taxonomy.suggest", result)
|
|
23
|
+
else:
|
|
24
|
+
lines: list[str] = []
|
|
25
|
+
cats = result["category_suggestions"]
|
|
26
|
+
lines.append(f"category_suggestions: {', '.join(cats)}")
|
|
27
|
+
tags = result["tag_suggestions"]
|
|
28
|
+
lines.append(f"tag_suggestions: {', '.join(tags)}")
|
|
29
|
+
rejected = result["rejected_reserved"]
|
|
30
|
+
if rejected:
|
|
31
|
+
lines.append(f"rejected_reserved: {', '.join(rejected)}")
|
|
32
|
+
lines.append(f"source: {', '.join(result['source'])}")
|
|
33
|
+
print("\n".join(lines))
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_taxonomy(
|
|
38
|
+
subparsers: argparse._SubParsersAction,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Register the ``courses taxonomy`` subcommand group."""
|
|
41
|
+
taxonomy = subparsers.add_parser(
|
|
42
|
+
"taxonomy",
|
|
43
|
+
help="Suggest category and tags from local course bundle text",
|
|
44
|
+
)
|
|
45
|
+
taxonomy_sub = taxonomy.add_subparsers(
|
|
46
|
+
dest="courses_taxonomy_command",
|
|
47
|
+
required=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
suggest = taxonomy_sub.add_parser(
|
|
51
|
+
"suggest",
|
|
52
|
+
help="Suggest category and tags from SKILL.md and capabilities.yaml",
|
|
53
|
+
parents=[COMMON_PARSER],
|
|
54
|
+
)
|
|
55
|
+
suggest.add_argument(
|
|
56
|
+
"--bundle-dir",
|
|
57
|
+
type=Path,
|
|
58
|
+
required=True,
|
|
59
|
+
help="Path to the course bundle directory",
|
|
60
|
+
)
|
|
61
|
+
suggest.set_defaults(handler=handle_taxonomy_suggest)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Deterministic taxonomy suggestions from local course bundle text.
|
|
3
|
+
|
|
4
|
+
Reads SKILL.md frontmatter and body plus an optional
|
|
5
|
+
``course/capabilities.yaml`` summary, then maps keywords to category
|
|
6
|
+
slugs and tag slugs. No LLM calls — the mapping is deterministic so
|
|
7
|
+
agents get reproducible suggestions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from cli._taxonomy import (
|
|
19
|
+
RESERVED_TAG_SLUGS,
|
|
20
|
+
TaxonomyValidationError,
|
|
21
|
+
normalize_tag,
|
|
22
|
+
)
|
|
23
|
+
from cli.commands.courses._taxonomy_data import (
|
|
24
|
+
CATEGORY_KEYWORDS,
|
|
25
|
+
STOP_WORDS,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_SKILL_BODY_READ_BYTES = 4096
|
|
29
|
+
_MIN_TOKEN_LENGTH = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_skill_frontmatter(skill_path: Path) -> dict[str, Any] | None:
|
|
33
|
+
"""Parse YAML frontmatter from SKILL.md, returning None if absent."""
|
|
34
|
+
if not skill_path.is_file():
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
text = skill_path.read_text(encoding="utf-8")
|
|
38
|
+
except OSError:
|
|
39
|
+
return None
|
|
40
|
+
if not text.startswith("---"):
|
|
41
|
+
return None
|
|
42
|
+
end = text.find("\n---\n", 3)
|
|
43
|
+
if end < 0:
|
|
44
|
+
stripped = text.rstrip()
|
|
45
|
+
if stripped.endswith("---") and text.count("---") >= 2:
|
|
46
|
+
end = stripped.rfind("---")
|
|
47
|
+
block = text[3:end].rstrip()
|
|
48
|
+
else:
|
|
49
|
+
return None
|
|
50
|
+
else:
|
|
51
|
+
block = text[3:end]
|
|
52
|
+
try:
|
|
53
|
+
parsed = yaml.safe_load(block)
|
|
54
|
+
except yaml.YAMLError:
|
|
55
|
+
return None
|
|
56
|
+
return parsed if isinstance(parsed, dict) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_skill_body_head(skill_path: Path) -> str:
|
|
60
|
+
"""Return the first 4 KB of SKILL.md body (after frontmatter)."""
|
|
61
|
+
if not skill_path.is_file():
|
|
62
|
+
return ""
|
|
63
|
+
try:
|
|
64
|
+
text = skill_path.read_text(encoding="utf-8")
|
|
65
|
+
except OSError:
|
|
66
|
+
return ""
|
|
67
|
+
if text.startswith("---"):
|
|
68
|
+
end = text.find("\n---\n", 3)
|
|
69
|
+
body = text[end + 5 :] if end >= 0 else ""
|
|
70
|
+
else:
|
|
71
|
+
body = text
|
|
72
|
+
return body[:_SKILL_BODY_READ_BYTES]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _read_capabilities_summary(bundle_dir: Path) -> str:
|
|
76
|
+
"""Return a text blob from ``course/capabilities.yaml`` if present."""
|
|
77
|
+
cap_path = bundle_dir / "course" / "capabilities.yaml"
|
|
78
|
+
if not cap_path.is_file():
|
|
79
|
+
return ""
|
|
80
|
+
try:
|
|
81
|
+
data = yaml.safe_load(cap_path.read_text(encoding="utf-8"))
|
|
82
|
+
except (OSError, yaml.YAMLError):
|
|
83
|
+
return ""
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
return ""
|
|
86
|
+
parts: list[str] = []
|
|
87
|
+
summary = data.get("summary")
|
|
88
|
+
if isinstance(summary, str):
|
|
89
|
+
parts.append(summary)
|
|
90
|
+
tools = data.get("tools")
|
|
91
|
+
if isinstance(tools, list):
|
|
92
|
+
parts.extend(str(t) for t in tools)
|
|
93
|
+
return " ".join(parts)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _tokenize(text: str) -> list[str]:
|
|
97
|
+
"""Lowercase, split on non-alphanumeric, drop stops and short tokens."""
|
|
98
|
+
raw = re.findall(r"[a-zA-Z0-9_-]+", text.lower())
|
|
99
|
+
tokens: list[str] = []
|
|
100
|
+
for word in raw:
|
|
101
|
+
for segment in word.split("-"):
|
|
102
|
+
segment = segment.strip("-")
|
|
103
|
+
if len(segment) >= _MIN_TOKEN_LENGTH and segment not in STOP_WORDS:
|
|
104
|
+
tokens.append(segment)
|
|
105
|
+
if len(word) >= _MIN_TOKEN_LENGTH and word not in STOP_WORDS:
|
|
106
|
+
tokens.append(word)
|
|
107
|
+
return tokens
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _suggest_categories(tokens: list[str]) -> list[str]:
|
|
111
|
+
"""Map tokens to category slugs by keyword overlap."""
|
|
112
|
+
token_set = set(tokens)
|
|
113
|
+
matches: list[str] = []
|
|
114
|
+
for category, keywords in CATEGORY_KEYWORDS.items():
|
|
115
|
+
if token_set & keywords:
|
|
116
|
+
matches.append(category)
|
|
117
|
+
if not matches:
|
|
118
|
+
return ["other"]
|
|
119
|
+
# CATEGORY_SLUGS is a frozenset (unordered); iterate CATEGORY_KEYWORDS
|
|
120
|
+
# keys instead so the output order is deterministic across processes.
|
|
121
|
+
match_set = set(matches)
|
|
122
|
+
return [c for c in CATEGORY_KEYWORDS if c in match_set]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _suggest_tags(tokens: list[str]) -> tuple[list[str], list[str]]:
|
|
126
|
+
"""Convert tokens to valid tag slugs, rejecting reserved labels.
|
|
127
|
+
|
|
128
|
+
Returns ``(tags, rejected_reserved)``.
|
|
129
|
+
"""
|
|
130
|
+
seen: set[str] = set()
|
|
131
|
+
tags: list[str] = []
|
|
132
|
+
rejected: list[str] = []
|
|
133
|
+
for token in tokens:
|
|
134
|
+
# Check reserved labels before normalize_tag, which raises on
|
|
135
|
+
# reserved slugs — we want to report them, not silently drop.
|
|
136
|
+
normalized = token.strip().lower().replace(" ", "-").replace("_", "-")
|
|
137
|
+
if normalized in RESERVED_TAG_SLUGS:
|
|
138
|
+
if normalized not in seen:
|
|
139
|
+
seen.add(normalized)
|
|
140
|
+
rejected.append(normalized)
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
tag = normalize_tag(token)
|
|
144
|
+
except TaxonomyValidationError:
|
|
145
|
+
continue
|
|
146
|
+
if tag in seen:
|
|
147
|
+
continue
|
|
148
|
+
seen.add(tag)
|
|
149
|
+
tags.append(tag)
|
|
150
|
+
return tags[:20], rejected
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def suggest_taxonomy(
|
|
154
|
+
bundle_dir: Path,
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""Produce deterministic category and tag suggestions for a bundle.
|
|
157
|
+
|
|
158
|
+
Reads ``SKILL.md`` frontmatter (name, description) and body, plus
|
|
159
|
+
``course/capabilities.yaml`` summary/tools. No network or LLM calls.
|
|
160
|
+
"""
|
|
161
|
+
sources: list[str] = []
|
|
162
|
+
skill_path = bundle_dir / "SKILL.md"
|
|
163
|
+
text_parts: list[str] = []
|
|
164
|
+
|
|
165
|
+
frontmatter = _read_skill_frontmatter(skill_path)
|
|
166
|
+
if frontmatter is not None:
|
|
167
|
+
sources.append("SKILL.md")
|
|
168
|
+
name = frontmatter.get("name")
|
|
169
|
+
description = frontmatter.get("description")
|
|
170
|
+
if isinstance(name, str):
|
|
171
|
+
text_parts.append(name)
|
|
172
|
+
if isinstance(description, str):
|
|
173
|
+
text_parts.append(description)
|
|
174
|
+
|
|
175
|
+
body_head = _read_skill_body_head(skill_path)
|
|
176
|
+
if body_head:
|
|
177
|
+
if "SKILL.md" not in sources:
|
|
178
|
+
sources.append("SKILL.md")
|
|
179
|
+
text_parts.append(body_head)
|
|
180
|
+
|
|
181
|
+
cap_summary = _read_capabilities_summary(bundle_dir)
|
|
182
|
+
if cap_summary:
|
|
183
|
+
sources.append("course/capabilities.yaml")
|
|
184
|
+
text_parts.append(cap_summary)
|
|
185
|
+
|
|
186
|
+
combined = " ".join(text_parts)
|
|
187
|
+
tokens = _tokenize(combined)
|
|
188
|
+
|
|
189
|
+
category_suggestions = _suggest_categories(tokens)
|
|
190
|
+
tag_suggestions, rejected_reserved = _suggest_tags(tokens)
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"category_suggestions": category_suggestions,
|
|
194
|
+
"tag_suggestions": tag_suggestions,
|
|
195
|
+
"rejected_reserved": rejected_reserved,
|
|
196
|
+
"source": sources,
|
|
197
|
+
}
|