agent-sin 0.1.11 → 0.1.15
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.
- package/CHANGELOG.md +79 -0
- package/README.md +2 -1
- package/builtin-skills/_shared/_todo_lib.py +290 -0
- package/builtin-skills/even-g2-setup/main.ts +896 -0
- package/builtin-skills/even-g2-setup/skill.yaml +133 -0
- package/builtin-skills/memo-delete/main.py +28 -107
- package/builtin-skills/memo-delete/skill.yaml +10 -21
- package/builtin-skills/memo-index/main.py +96 -64
- package/builtin-skills/memo-index/skill.yaml +4 -10
- package/builtin-skills/memo-list/main.py +179 -0
- package/builtin-skills/memo-list/skill.yaml +51 -0
- package/builtin-skills/memo-save/main.py +191 -25
- package/builtin-skills/memo-save/skill.yaml +29 -5
- package/builtin-skills/memo-search/main.py +38 -18
- package/builtin-skills/memo-vector-search/main.py +11 -6
- package/builtin-skills/nightly-topic-knowledge/_feedback_lib.py +391 -0
- package/builtin-skills/nightly-topic-knowledge/_topics_lib.py +415 -0
- package/builtin-skills/nightly-topic-knowledge/main.py +403 -0
- package/builtin-skills/nightly-topic-knowledge/skill.yaml +88 -0
- package/builtin-skills/schedule-add/main.py +26 -0
- package/builtin-skills/service-restart/main.ts +249 -0
- package/builtin-skills/service-restart/skill.yaml +49 -0
- package/builtin-skills/todo-add/main.py +3 -1
- package/builtin-skills/todo-delete/main.py +3 -1
- package/builtin-skills/todo-done/main.py +3 -1
- package/builtin-skills/todo-list/main.py +4 -1
- package/builtin-skills/todo-tick/main.py +3 -1
- package/builtin-skills/topic-knowledge-read/main.py +118 -0
- package/builtin-skills/topic-knowledge-read/skill.yaml +49 -0
- package/dist/builder/build-action-classifier.d.ts +18 -0
- package/dist/builder/build-action-classifier.js +82 -1
- package/dist/builder/build-flow.d.ts +33 -4
- package/dist/builder/build-flow.js +251 -89
- package/dist/builder/builder-session.d.ts +1 -1
- package/dist/builder/builder-session.js +112 -7
- package/dist/builder/conversation-router.d.ts +4 -2
- package/dist/builder/conversation-router.js +19 -2
- package/dist/cli/index.js +323 -20
- package/dist/core/ai-provider.d.ts +1 -0
- package/dist/core/ai-provider.js +8 -3
- package/dist/core/chat-engine.d.ts +10 -3
- package/dist/core/chat-engine.js +1563 -197
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +82 -0
- package/dist/core/daily-memory-promotion.d.ts +7 -0
- package/dist/core/daily-memory-promotion.js +568 -14
- package/dist/core/image-attachments.d.ts +31 -0
- package/dist/core/image-attachments.js +237 -0
- package/dist/core/logger.d.ts +2 -1
- package/dist/core/logger.js +77 -1
- package/dist/core/memo-migration.d.ts +3 -0
- package/dist/core/memo-migration.js +422 -0
- package/dist/core/native-modules.d.ts +24 -0
- package/dist/core/native-modules.js +99 -0
- package/dist/core/notifier.d.ts +8 -3
- package/dist/core/notifier.js +191 -17
- package/dist/core/obsidian-vault.d.ts +19 -0
- package/dist/core/obsidian-vault.js +477 -0
- package/dist/core/operating-model.d.ts +2 -0
- package/dist/core/operating-model.js +15 -0
- package/dist/core/output-writer.d.ts +3 -2
- package/dist/core/output-writer.js +108 -7
- package/dist/core/profile-memory.js +22 -1
- package/dist/core/runtime.d.ts +2 -0
- package/dist/core/runtime.js +9 -1
- package/dist/core/secrets.d.ts +4 -0
- package/dist/core/secrets.js +34 -0
- package/dist/core/skill-history.d.ts +44 -0
- package/dist/core/skill-history.js +329 -0
- package/dist/core/skill-registry.d.ts +5 -0
- package/dist/core/skill-registry.js +11 -0
- package/dist/discord/bot.d.ts +13 -0
- package/dist/discord/bot.js +542 -10
- package/dist/even-g2/gateway.d.ts +15 -0
- package/dist/even-g2/gateway.js +868 -0
- package/dist/runtimes/codex-app-server.d.ts +5 -1
- package/dist/runtimes/codex-app-server.js +147 -8
- package/dist/runtimes/python-runner.js +82 -0
- package/dist/runtimes/typescript-runner.js +13 -1
- package/dist/skills-sdk/types.d.ts +19 -4
- package/dist/telegram/bot.d.ts +1 -0
- package/dist/telegram/bot.js +122 -31
- package/package.json +3 -1
- package/templates/even-g2-agent/README.md +83 -0
- package/templates/even-g2-agent/app.json +20 -0
- package/templates/even-g2-agent/index.html +31 -0
- package/templates/even-g2-agent/package-lock.json +1836 -0
- package/templates/even-g2-agent/package.json +22 -0
- package/templates/even-g2-agent/scripts/qr-auto.mjs +182 -0
- package/templates/even-g2-agent/src/embedded-config.ts +4 -0
- package/templates/even-g2-agent/src/main.ts +539 -0
- package/templates/even-g2-agent/src/style.css +70 -0
- package/templates/even-g2-agent/tsconfig.json +11 -0
- package/templates/skill-python/main.py +20 -2
- package/templates/skill-python/skill.yaml +9 -0
- package/templates/skill-typescript/main.ts +40 -5
- package/templates/skill-typescript/skill.yaml +9 -0
|
@@ -9,7 +9,7 @@ memo-index で索引化した Chroma collection に対してクエリを embeddi
|
|
|
9
9
|
args.collection: 索引コレクション名 (default: memo)
|
|
10
10
|
|
|
11
11
|
出力:
|
|
12
|
-
data.matches: [{
|
|
12
|
+
data.matches: [{title, file, tags, updated, distance, snippet}]
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
@@ -86,13 +86,17 @@ async def run(ctx, input):
|
|
|
86
86
|
dists = (r.get("distances") or [[]])[0]
|
|
87
87
|
for doc, meta, dist in zip(docs, metas, dists):
|
|
88
88
|
meta = meta or {}
|
|
89
|
+
snippet = (doc or "").strip().splitlines()[0] if doc else ""
|
|
90
|
+
if len(snippet) > 100:
|
|
91
|
+
snippet = snippet[:97] + "..."
|
|
89
92
|
matches.append(
|
|
90
93
|
{
|
|
91
|
-
"
|
|
94
|
+
"title": meta.get("title", ""),
|
|
92
95
|
"file": meta.get("file", ""),
|
|
93
|
-
"
|
|
94
|
-
"
|
|
96
|
+
"tags": meta.get("tags", ""),
|
|
97
|
+
"updated": meta.get("updated", ""),
|
|
95
98
|
"distance": float(dist),
|
|
99
|
+
"snippet": snippet,
|
|
96
100
|
}
|
|
97
101
|
)
|
|
98
102
|
|
|
@@ -103,8 +107,9 @@ async def run(ctx, input):
|
|
|
103
107
|
|
|
104
108
|
lines = [loc.t("Closest memos:", "近いメモ:")]
|
|
105
109
|
for i, m in enumerate(matches, start=1):
|
|
106
|
-
lines.append(f" {i}) {m['
|
|
107
|
-
|
|
110
|
+
lines.append(f" {i}) {m['title']} dist={m['distance']:.3f}")
|
|
111
|
+
if m["snippet"]:
|
|
112
|
+
lines.append(f" {m['snippet']}")
|
|
108
113
|
return result_value(
|
|
109
114
|
"ok", loc.t(f"{len(matches)} matches", f"{len(matches)}件見つかりました"), "\n".join(lines), matches
|
|
110
115
|
)
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Helpers for the skill-feedback AI step of nightly-topic-knowledge.
|
|
2
|
+
|
|
3
|
+
Reads the current skill catalog (id / name / description / phrases / enabled
|
|
4
|
+
state) from the builtin and user skills directories, builds an LLM prompt that
|
|
5
|
+
asks for actionable suggestions derived from the previous day's sources, and
|
|
6
|
+
parses the JSON response into a normalized list of proposals that can be
|
|
7
|
+
appended to ctx.history and broadcast over the configured notification
|
|
8
|
+
channel.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any, Iterable
|
|
17
|
+
|
|
18
|
+
try: # PyYAML is already used by other builtin skills.
|
|
19
|
+
import yaml as _yaml # type: ignore
|
|
20
|
+
HAS_PYYAML = True
|
|
21
|
+
except Exception:
|
|
22
|
+
HAS_PYYAML = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PROPOSAL_TYPES = ("misalign", "repeat", "unused", "user-feedback")
|
|
26
|
+
TARGET_KINDS = ("existing", "new", "rule")
|
|
27
|
+
TARGET_CHANGES = (
|
|
28
|
+
"description",
|
|
29
|
+
"phrases",
|
|
30
|
+
"main",
|
|
31
|
+
"new",
|
|
32
|
+
"claude-md",
|
|
33
|
+
"other",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
MAX_PROPOSALS = 12
|
|
37
|
+
MAX_TEXT_LEN = 600
|
|
38
|
+
MAX_TITLE_LEN = 120
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------- skill catalog ----------
|
|
42
|
+
|
|
43
|
+
def collect_skill_summary(skills_dir: str | None, builtin_skills_dir: str | None) -> list[dict[str, Any]]:
|
|
44
|
+
"""Walk both skill roots and return a deduped list of skill summaries.
|
|
45
|
+
|
|
46
|
+
User skills shadow builtin ids when override=true; otherwise builtins win,
|
|
47
|
+
matching the runtime's registry behaviour. Keeps the prompt small by only
|
|
48
|
+
extracting fields the LLM actually needs.
|
|
49
|
+
"""
|
|
50
|
+
by_id: dict[str, dict[str, Any]] = {}
|
|
51
|
+
for source, root in (("builtin", builtin_skills_dir), ("user", skills_dir)):
|
|
52
|
+
if not root or not os.path.isdir(root):
|
|
53
|
+
continue
|
|
54
|
+
for entry in sorted(os.listdir(root)):
|
|
55
|
+
if entry.startswith(".") or entry.startswith("_"):
|
|
56
|
+
continue
|
|
57
|
+
yaml_path = os.path.join(root, entry, "skill.yaml")
|
|
58
|
+
if not os.path.isfile(yaml_path):
|
|
59
|
+
continue
|
|
60
|
+
manifest = _read_yaml(yaml_path)
|
|
61
|
+
if not isinstance(manifest, dict):
|
|
62
|
+
continue
|
|
63
|
+
summary = _summarize_manifest(manifest, source)
|
|
64
|
+
if not summary:
|
|
65
|
+
continue
|
|
66
|
+
sid = summary["id"]
|
|
67
|
+
if source == "user" and sid in by_id and not bool(manifest.get("override")):
|
|
68
|
+
continue
|
|
69
|
+
by_id[sid] = summary
|
|
70
|
+
return sorted(by_id.values(), key=lambda s: s["id"])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_yaml(path: str) -> Any:
|
|
74
|
+
try:
|
|
75
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
76
|
+
text = f.read()
|
|
77
|
+
except Exception:
|
|
78
|
+
return None
|
|
79
|
+
if HAS_PYYAML:
|
|
80
|
+
try:
|
|
81
|
+
return _yaml.safe_load(text)
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
84
|
+
return _fallback_skill_yaml(text)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fallback_skill_yaml(text: str) -> dict[str, Any]:
|
|
88
|
+
"""Minimal scalar/list extractor used only when PyYAML is missing.
|
|
89
|
+
|
|
90
|
+
Skill.yaml uses nested structures for invocation/input/etc. that the
|
|
91
|
+
fallback can't fully parse — but the few top-level fields we care about
|
|
92
|
+
(id / name / description / enabled) are simple strings, so we can salvage
|
|
93
|
+
enough to keep the prompt useful instead of failing the whole step.
|
|
94
|
+
"""
|
|
95
|
+
out: dict[str, Any] = {}
|
|
96
|
+
for line in text.splitlines():
|
|
97
|
+
match = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*?)\s*$", line)
|
|
98
|
+
if not match:
|
|
99
|
+
continue
|
|
100
|
+
key, raw = match.group(1), match.group(2)
|
|
101
|
+
if not raw or raw.startswith("#"):
|
|
102
|
+
continue
|
|
103
|
+
if raw.startswith(('"', "'")) and raw.endswith(raw[0]):
|
|
104
|
+
raw = raw[1:-1]
|
|
105
|
+
if key in out:
|
|
106
|
+
continue
|
|
107
|
+
out[key] = raw
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _summarize_manifest(manifest: dict[str, Any], source: str) -> dict[str, Any] | None:
|
|
112
|
+
sid = manifest.get("id")
|
|
113
|
+
if not isinstance(sid, str) or not sid:
|
|
114
|
+
return None
|
|
115
|
+
description = _pick_localized(manifest, "description")
|
|
116
|
+
invocation = manifest.get("invocation") or {}
|
|
117
|
+
phrases = _pick_localized_list(invocation, "phrases")
|
|
118
|
+
command = invocation.get("command") if isinstance(invocation, dict) else None
|
|
119
|
+
enabled = manifest.get("enabled")
|
|
120
|
+
return {
|
|
121
|
+
"id": sid,
|
|
122
|
+
"name": _pick_localized(manifest, "name") or sid,
|
|
123
|
+
"description": description or "",
|
|
124
|
+
"phrases": phrases,
|
|
125
|
+
"command": command if isinstance(command, str) else "",
|
|
126
|
+
"source": source,
|
|
127
|
+
"enabled": False if enabled is False else True,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _pick_localized(manifest: dict[str, Any], key: str) -> str:
|
|
132
|
+
i18n = manifest.get(f"{key}_i18n")
|
|
133
|
+
if isinstance(i18n, dict):
|
|
134
|
+
ja = i18n.get("ja")
|
|
135
|
+
if isinstance(ja, str) and ja.strip():
|
|
136
|
+
return ja.strip()
|
|
137
|
+
en = i18n.get("en")
|
|
138
|
+
if isinstance(en, str) and en.strip():
|
|
139
|
+
return en.strip()
|
|
140
|
+
value = manifest.get(key)
|
|
141
|
+
if isinstance(value, str):
|
|
142
|
+
return value.strip()
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _pick_localized_list(node: Any, key: str) -> list[str]:
|
|
147
|
+
if not isinstance(node, dict):
|
|
148
|
+
return []
|
|
149
|
+
i18n = node.get(f"{key}_i18n")
|
|
150
|
+
if isinstance(i18n, dict):
|
|
151
|
+
ja = i18n.get("ja")
|
|
152
|
+
if isinstance(ja, list):
|
|
153
|
+
return [str(x).strip() for x in ja if isinstance(x, str) and x.strip()]
|
|
154
|
+
en = i18n.get("en")
|
|
155
|
+
if isinstance(en, list):
|
|
156
|
+
return [str(x).strip() for x in en if isinstance(x, str) and x.strip()]
|
|
157
|
+
raw = node.get(key)
|
|
158
|
+
if isinstance(raw, list):
|
|
159
|
+
return [str(x).strip() for x in raw if isinstance(x, str) and x.strip()]
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------- prompt ----------
|
|
164
|
+
|
|
165
|
+
def build_feedback_prompt(
|
|
166
|
+
date_str: str,
|
|
167
|
+
source_bundle: str,
|
|
168
|
+
skill_catalog: Iterable[dict[str, Any]],
|
|
169
|
+
) -> str:
|
|
170
|
+
catalog_lines: list[str] = []
|
|
171
|
+
for skill in skill_catalog:
|
|
172
|
+
state = "" if skill.get("enabled") else " [disabled]"
|
|
173
|
+
phrases = skill.get("phrases") or []
|
|
174
|
+
phrases_text = " / ".join(phrases[:6]) if phrases else ""
|
|
175
|
+
line = f"- {skill['id']}{state}: {skill.get('description', '').strip()}"
|
|
176
|
+
if phrases_text:
|
|
177
|
+
line += f" (phrases: {phrases_text})"
|
|
178
|
+
catalog_lines.append(line)
|
|
179
|
+
catalog_text = "\n".join(catalog_lines) if catalog_lines else "(no skills registered)"
|
|
180
|
+
|
|
181
|
+
schema = {
|
|
182
|
+
"proposals": [
|
|
183
|
+
{
|
|
184
|
+
"type": "<one of: misalign | repeat | unused | user-feedback>",
|
|
185
|
+
"title": "<short Japanese title under 60 chars>",
|
|
186
|
+
"citation": "<short quote / timestamp showing where in the sources this came from>",
|
|
187
|
+
"proposal": "<concrete change: what to add/remove/rename and where>",
|
|
188
|
+
"target_kind": "<existing | new | rule>",
|
|
189
|
+
"target_skill": "<existing skill id, or proposed new skill id (kebab-case), or empty for rule>",
|
|
190
|
+
"target_change": "<description | phrases | main | new | claude-md | other>",
|
|
191
|
+
}
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
instructions = [
|
|
196
|
+
"You are reviewing one day of an individual user's conversations / daily memory / notes",
|
|
197
|
+
"to look for ways to improve the assistant's skill set. The goal is to surface concrete,",
|
|
198
|
+
"actionable proposals that the user can later batch-apply by replying with item numbers.",
|
|
199
|
+
"",
|
|
200
|
+
"Detect four categories — keep the `type` field to one of these literal strings:",
|
|
201
|
+
"- misalign: the assistant misread the user's intent, took the wrong tool, or had to redo work.",
|
|
202
|
+
"- repeat: the user asked for the same hand-driven task more than once → should become a skill.",
|
|
203
|
+
"- unused: an existing skill was not called when it should have been, or its phrases / description",
|
|
204
|
+
" did not match how the user actually asked for it.",
|
|
205
|
+
"- user-feedback: explicit corrective or affirming feedback the user gave that should change",
|
|
206
|
+
" default behaviour (rule / description / phrases).",
|
|
207
|
+
"",
|
|
208
|
+
"Hard rules:",
|
|
209
|
+
"- Output ONE JSON object only. No prose, no markdown fences.",
|
|
210
|
+
f"- At most {MAX_PROPOSALS} proposals. Skip trivial or speculative ones.",
|
|
211
|
+
"- Skip anything already obviously satisfied by the existing skills below.",
|
|
212
|
+
"- For `target_kind: existing`, `target_skill` MUST match an id from the skill catalog.",
|
|
213
|
+
"- For `target_kind: new`, propose a fresh kebab-case id that does NOT collide with any existing id.",
|
|
214
|
+
"- For `target_kind: rule`, leave `target_skill` empty and explain in `proposal` where the rule lives",
|
|
215
|
+
" (e.g. CLAUDE.md, feedback memory, runtime gate).",
|
|
216
|
+
"- `citation` must include a short verbatim quote or timestamp so the user can verify.",
|
|
217
|
+
"- All text in Japanese. Keep each field under 600 characters.",
|
|
218
|
+
"",
|
|
219
|
+
f"Date: {date_str}",
|
|
220
|
+
"",
|
|
221
|
+
"Current skill catalog (id [disabled?]: description (phrases: ...)):",
|
|
222
|
+
catalog_text,
|
|
223
|
+
"",
|
|
224
|
+
"JSON schema (output shape):",
|
|
225
|
+
json.dumps(schema, ensure_ascii=False, indent=2),
|
|
226
|
+
"",
|
|
227
|
+
"Sources:",
|
|
228
|
+
source_bundle or "(empty)",
|
|
229
|
+
]
|
|
230
|
+
return "\n".join(instructions)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------- response parsing ----------
|
|
234
|
+
|
|
235
|
+
_JSON_OBJECT_PATTERN = re.compile(r"\{[\s\S]*\}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def parse_feedback_response(text: str) -> list[dict[str, Any]]:
|
|
239
|
+
raw = (text or "").strip()
|
|
240
|
+
if not raw:
|
|
241
|
+
return []
|
|
242
|
+
fenced = re.match(r"^```(?:json)?\s*([\s\S]*?)\s*```$", raw)
|
|
243
|
+
if fenced:
|
|
244
|
+
raw = fenced.group(1).strip()
|
|
245
|
+
parsed: Any
|
|
246
|
+
try:
|
|
247
|
+
parsed = json.loads(raw)
|
|
248
|
+
except Exception:
|
|
249
|
+
match = _JSON_OBJECT_PATTERN.search(raw)
|
|
250
|
+
if not match:
|
|
251
|
+
return []
|
|
252
|
+
try:
|
|
253
|
+
parsed = json.loads(match.group(0))
|
|
254
|
+
except Exception:
|
|
255
|
+
return []
|
|
256
|
+
if not isinstance(parsed, dict):
|
|
257
|
+
return []
|
|
258
|
+
items = parsed.get("proposals")
|
|
259
|
+
if not isinstance(items, list):
|
|
260
|
+
return []
|
|
261
|
+
out: list[dict[str, Any]] = []
|
|
262
|
+
known_ids: set[str] = set()
|
|
263
|
+
for raw_item in items:
|
|
264
|
+
normalized = _normalize_proposal(raw_item, known_ids)
|
|
265
|
+
if normalized is None:
|
|
266
|
+
continue
|
|
267
|
+
out.append(normalized)
|
|
268
|
+
if len(out) >= MAX_PROPOSALS:
|
|
269
|
+
break
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _normalize_proposal(item: Any, known_ids: set[str]) -> dict[str, Any] | None:
|
|
274
|
+
if not isinstance(item, dict):
|
|
275
|
+
return None
|
|
276
|
+
ptype = _clamp_enum(item.get("type"), PROPOSAL_TYPES)
|
|
277
|
+
if not ptype:
|
|
278
|
+
return None
|
|
279
|
+
title = _clean_str(item.get("title"), MAX_TITLE_LEN)
|
|
280
|
+
if not title:
|
|
281
|
+
return None
|
|
282
|
+
citation = _clean_str(item.get("citation"), MAX_TEXT_LEN)
|
|
283
|
+
proposal = _clean_str(item.get("proposal"), MAX_TEXT_LEN)
|
|
284
|
+
if not proposal:
|
|
285
|
+
return None
|
|
286
|
+
target_kind = _clamp_enum(item.get("target_kind"), TARGET_KINDS) or "rule"
|
|
287
|
+
target_skill = _sanitize_skill_id(item.get("target_skill"))
|
|
288
|
+
if target_kind == "existing" and not target_skill:
|
|
289
|
+
return None
|
|
290
|
+
if target_kind == "new" and not target_skill:
|
|
291
|
+
return None
|
|
292
|
+
target_change = _clamp_enum(item.get("target_change"), TARGET_CHANGES) or "other"
|
|
293
|
+
# Avoid duplicate identical proposals from the model.
|
|
294
|
+
fingerprint = f"{ptype}|{title}|{target_kind}|{target_skill}"
|
|
295
|
+
if fingerprint in known_ids:
|
|
296
|
+
return None
|
|
297
|
+
known_ids.add(fingerprint)
|
|
298
|
+
return {
|
|
299
|
+
"type": ptype,
|
|
300
|
+
"title": title,
|
|
301
|
+
"citation": citation,
|
|
302
|
+
"proposal": proposal,
|
|
303
|
+
"target_kind": target_kind,
|
|
304
|
+
"target_skill": target_skill,
|
|
305
|
+
"target_change": target_change,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _clean_str(value: Any, cap: int) -> str:
|
|
310
|
+
if not isinstance(value, str):
|
|
311
|
+
return ""
|
|
312
|
+
text = value.strip()
|
|
313
|
+
if len(text) > cap:
|
|
314
|
+
text = text[:cap].rstrip() + "…"
|
|
315
|
+
return text
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _clamp_enum(value: Any, allowed: Iterable[str]) -> str | None:
|
|
319
|
+
if not isinstance(value, str):
|
|
320
|
+
return None
|
|
321
|
+
text = value.strip().lower()
|
|
322
|
+
for candidate in allowed:
|
|
323
|
+
if text == candidate:
|
|
324
|
+
return candidate
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _sanitize_skill_id(value: Any) -> str:
|
|
329
|
+
if not isinstance(value, str):
|
|
330
|
+
return ""
|
|
331
|
+
text = value.strip().lower()
|
|
332
|
+
text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
|
333
|
+
if not text:
|
|
334
|
+
return ""
|
|
335
|
+
if len(text) > 48:
|
|
336
|
+
text = text[:48].rstrip("-")
|
|
337
|
+
if not re.match(r"^[a-z][a-z0-9-]*$", text):
|
|
338
|
+
return ""
|
|
339
|
+
return text
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ---------- formatting for Telegram + history ----------
|
|
343
|
+
|
|
344
|
+
TYPE_LABEL = {
|
|
345
|
+
"misalign": "ズレ",
|
|
346
|
+
"repeat": "繰り返し",
|
|
347
|
+
"unused": "未活用",
|
|
348
|
+
"user-feedback": "フィードバック",
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
TARGET_KIND_LABEL = {
|
|
352
|
+
"existing": "既存",
|
|
353
|
+
"new": "新規",
|
|
354
|
+
"rule": "ルール",
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def format_proposal_for_history(date_str: str, index: int, proposal: dict[str, Any]) -> str:
|
|
359
|
+
type_label = TYPE_LABEL.get(proposal["type"], proposal["type"])
|
|
360
|
+
kind_label = TARGET_KIND_LABEL.get(proposal["target_kind"], proposal["target_kind"])
|
|
361
|
+
target = proposal.get("target_skill") or ""
|
|
362
|
+
target_block = (
|
|
363
|
+
f"{kind_label}: {target}" if target else kind_label
|
|
364
|
+
)
|
|
365
|
+
parts = [
|
|
366
|
+
f"#{index}. [{type_label}] {proposal['title']}",
|
|
367
|
+
f"対象: {target_block} / {proposal.get('target_change') or 'other'}",
|
|
368
|
+
]
|
|
369
|
+
citation = proposal.get("citation")
|
|
370
|
+
if citation:
|
|
371
|
+
parts.append(f"該当: {citation}")
|
|
372
|
+
parts.append(f"提案: {proposal['proposal']}")
|
|
373
|
+
return "\n".join(parts)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def format_notification(date_str: str, proposals: list[dict[str, Any]], char_budget: int = 3500) -> str:
|
|
377
|
+
if not proposals:
|
|
378
|
+
return ""
|
|
379
|
+
header = f"📋 スキル振り返り {date_str} ({len(proposals)}件)\n返信例: 「1, 3 を適用」「2 は不要」"
|
|
380
|
+
lines: list[str] = [header]
|
|
381
|
+
used = len(header)
|
|
382
|
+
for index, proposal in enumerate(proposals, start=1):
|
|
383
|
+
body = format_proposal_for_history(date_str, index, proposal)
|
|
384
|
+
addition = "\n\n" + body
|
|
385
|
+
if used + len(addition) > char_budget:
|
|
386
|
+
remaining = len(proposals) - (index - 1)
|
|
387
|
+
lines.append(f"\n\n…他 {remaining} 件は保存済み(履歴参照)")
|
|
388
|
+
break
|
|
389
|
+
lines.append(addition)
|
|
390
|
+
used += len(addition)
|
|
391
|
+
return "".join(lines)
|