agentlas 1.0.27 → 1.0.29
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 +33 -0
- package/README.md +5 -2
- package/engine/agentlas-i18n.cjs +4 -4
- package/engine/agentlas-input.cjs +0 -2
- package/engine/agentlas-onboard.cjs +20 -0
- package/engine/agentlas-workforce.cjs +41 -8
- package/engine/agentlas.cjs +7 -1
- package/engine/commands/billing.cjs +3 -0
- package/engine/commands/creds.cjs +49 -1
- package/engine/commands/doctor.cjs +46 -3
- package/engine/commands/graph.cjs +1150 -0
- package/engine/commands/help.cjs +82 -6
- package/engine/commands/hep-cloud.cjs +9 -23
- package/engine/commands/hep-hub.cjs +9 -22
- package/engine/commands/hep-local.cjs +9 -24
- package/engine/commands/hep-network.cjs +9 -35
- package/engine/commands/index.cjs +49 -25
- package/engine/commands/mcp.cjs +6 -2
- package/engine/commands/native.cjs +18 -2
- package/engine/commands/plugin.cjs +22 -0
- package/engine/commands/roles.cjs +202 -0
- package/engine/commands/workforce.cjs +63 -12
- package/engine/graph/ask-model.cjs +131 -0
- package/engine/graph/interview.cjs +875 -0
- package/engine/graph/layout.cjs +137 -0
- package/engine/graph/package.cjs +223 -0
- package/engine/graph/vocabulary.generated.cjs +30 -0
- package/engine/hephaestus/local-core.cjs +159 -0
- package/engine/hephaestus/runtime.cjs +43 -9
- package/engine/runtimes/auth-evidence.cjs +78 -0
- package/engine/sessions/prompt.cjs +16 -0
- package/engine/sessions/session.cjs +9 -0
- package/engine/tools/access-notice.cjs +86 -0
- package/engine/ui/palette.cjs +6 -3
- package/engine/ui/repl.cjs +8 -2
- package/engine/workforce/deps.cjs +13 -0
- package/engine/workforce/local-core-transport.cjs +298 -0
- package/package.json +4 -3
- package/engine/commands/legacy-network.cjs +0 -29
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { layoutGraph, needsLayout } = require("./layout.cjs");
|
|
3
|
+
/*
|
|
4
|
+
* 그래프 인터뷰(터미널) — 데스크탑 shared/graph-blueprint.ts + electron/workflow/graph-interview.ts
|
|
5
|
+
* 와 **같은 계약**이어야 한다. 어긋나면 표면마다 다른 그래프가 만들어진다.
|
|
6
|
+
*
|
|
7
|
+
* 설계의 핵심 한 줄: **모델은 청사진만 말하고, 그래프는 코드가 짓는다.**
|
|
8
|
+
* 모델이 노드 id와 연결을 직접 쓰면, 실사용에서 사람이 겪은 결함이 그대로 재발한다
|
|
9
|
+
* (참/거짓 미선언 연결 → 두 갈래 동시 실행 · 고아 노드 · 상한 없는 반복 ·
|
|
10
|
+
* 아무도 만들지 않는 값 참조).
|
|
11
|
+
*
|
|
12
|
+
* 두 번째 규칙: **모르면 지어내지 말고 묻는다.** 특히 실행 시각과 "바깥으로 나가는가"는
|
|
13
|
+
* 절대 기본값을 쓰지 않는다 — 자동화는 사람이 없는 동안 돌기 때문이다.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const BLUEPRINT_SCHEMA = "agentlas.graph-blueprint.v1";
|
|
17
|
+
const MAX_QUESTIONS_PER_TURN = 3;
|
|
18
|
+
const MAX_INTERVIEW_ROUNDS = 6;
|
|
19
|
+
const MAX_STEPS = 20;
|
|
20
|
+
const MAX_REPEATS = 20;
|
|
21
|
+
|
|
22
|
+
// 갈림길이 쓸 수 있는 판단 방법. **커널이 실제로 실행하는 것과 같아야 한다.**
|
|
23
|
+
// 예전엔 "neq"가 있었는데 커널은 "ne"만 실행했다 — 제품이 자기가 못 읽는 자동화를 저장했다.
|
|
24
|
+
const CONDITION_OPS = ["truthy", "falsy", "eq", "ne", "gt", "lt", "contains"];
|
|
25
|
+
|
|
26
|
+
// ── 도구 결합 ──────────────────────────────────────────────────────────────
|
|
27
|
+
// 데스크탑 shared/graph-tool-binding.ts 와 **같은 명부**여야 한다. 어긋나면 터미널에서
|
|
28
|
+
// 만든 그래프가 데스크탑에서 켜지지 않는다(요구가 다르게 읽히므로).
|
|
29
|
+
const PROVIDER_CATALOG = [
|
|
30
|
+
{ id: "google_calendar", label: "Google 캘린더", group: "google", capabilities: ["calendar.events.list", "calendar.events.create"] },
|
|
31
|
+
{ id: "google_sheets", label: "Google 스프레드시트", group: "google", capabilities: ["sheets.rows.read", "sheets.rows.append"] },
|
|
32
|
+
{ id: "gmail", label: "Gmail", group: "google", capabilities: ["mail.messages.list", "mail.messages.send"] },
|
|
33
|
+
{ id: "outlook_calendar", label: "Outlook 캘린더", group: "microsoft", capabilities: ["calendar.events.list", "calendar.events.create"] },
|
|
34
|
+
{ id: "outlook_mail", label: "Outlook 메일", group: "microsoft", capabilities: ["mail.messages.list", "mail.messages.send"] },
|
|
35
|
+
{ id: "apple_calendar", label: "Apple 캘린더", group: "apple", capabilities: ["calendar.events.list", "calendar.events.create"] },
|
|
36
|
+
{ id: "slack", label: "Slack", group: "slack", capabilities: ["chat.messages.post", "chat.messages.list"] },
|
|
37
|
+
{ id: "notion", label: "Notion", group: "notion", capabilities: ["docs.pages.read", "docs.pages.create", "docs.database.query"] },
|
|
38
|
+
{ id: "github", label: "GitHub", group: "github", capabilities: ["code.issues.list", "code.issues.create", "code.repo.read"] },
|
|
39
|
+
{ id: "linear", label: "Linear", group: "atlassian", capabilities: ["tasks.issues.list", "tasks.issues.create"] },
|
|
40
|
+
{ id: "local_files", label: "이 컴퓨터의 파일", group: "local", capabilities: ["files.read", "files.write"] },
|
|
41
|
+
{ id: "web_search", label: "웹 검색", group: "other", capabilities: ["web.search"] },
|
|
42
|
+
];
|
|
43
|
+
const CAPABILITIES = [...new Set(PROVIDER_CATALOG.flatMap((p) => p.capabilities))].sort();
|
|
44
|
+
const findProvider = (id) => (id ? PROVIDER_CATALOG.find((p) => p.id === id) || null : null);
|
|
45
|
+
const providersFor = (capability) => PROVIDER_CATALOG.filter((p) => p.capabilities.includes(capability));
|
|
46
|
+
|
|
47
|
+
const CAPABILITY_LABEL = {
|
|
48
|
+
"calendar.events.list": "캘린더 일정 읽기", "calendar.events.create": "캘린더에 일정 넣기",
|
|
49
|
+
"sheets.rows.read": "스프레드시트 읽기", "sheets.rows.append": "스프레드시트에 추가",
|
|
50
|
+
"mail.messages.list": "메일 읽기", "mail.messages.send": "메일 보내기",
|
|
51
|
+
"chat.messages.post": "채팅에 올리기", "chat.messages.list": "채팅 읽기",
|
|
52
|
+
"docs.pages.read": "문서 읽기", "docs.pages.create": "문서 만들기",
|
|
53
|
+
"docs.database.query": "문서 데이터베이스 조회",
|
|
54
|
+
"code.issues.list": "이슈 읽기", "code.issues.create": "이슈 만들기", "code.repo.read": "코드 읽기",
|
|
55
|
+
"tasks.issues.list": "할 일 읽기", "tasks.issues.create": "할 일 만들기",
|
|
56
|
+
"files.read": "이 컴퓨터 파일 읽기", "files.write": "이 컴퓨터에 파일 쓰기",
|
|
57
|
+
"web.search": "웹 검색",
|
|
58
|
+
};
|
|
59
|
+
const CAPABILITY_CHOICES = CAPABILITIES.map((id) => CAPABILITY_LABEL[id] || id);
|
|
60
|
+
|
|
61
|
+
const OPS = new Set(CONDITION_OPS);
|
|
62
|
+
const VALUE_OPS = new Set(["contains", "eq", "ne", "gt", "lt"]);
|
|
63
|
+
const VAR_RE = /^[A-Za-z_][\w-]*$/;
|
|
64
|
+
|
|
65
|
+
function startInterview(request) {
|
|
66
|
+
return { request: String(request || "").trim(), answers: [], asked: [], round: 0, attempts: [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function recordAnswers(state, answers) {
|
|
70
|
+
return {
|
|
71
|
+
...state,
|
|
72
|
+
answers: [...state.answers, ...answers],
|
|
73
|
+
asked: [...new Set([...state.asked, ...answers.map((a) => a.questionId)])],
|
|
74
|
+
round: state.round + 1,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const RULES = [
|
|
79
|
+
"You are building an automation for someone who is not a developer. You will be asked to either",
|
|
80
|
+
"ASK questions or produce a BLUEPRINT. Never produce raw graph JSON, node ids, or edges.",
|
|
81
|
+
"",
|
|
82
|
+
"Ask rather than assume. These must come from the person, never from you:",
|
|
83
|
+
" · when it runs (a time, or 'whenever I give it a value') — never invent a time;",
|
|
84
|
+
" · whether a step goes OUTSIDE (posting, emailing, saving a file, paying) — never downgrade to read;",
|
|
85
|
+
" · what exactly each step should do, in enough detail that an agent can act without asking back;",
|
|
86
|
+
" · how many times a repeat may run.",
|
|
87
|
+
"Ask about what you genuinely cannot decide. Do not ask about things you can name yourself",
|
|
88
|
+
"(a sensible automation name, a variable name, the order of obvious steps).",
|
|
89
|
+
"",
|
|
90
|
+
"If the person says they do not know, or asks you to decide (\"you pick\", \"알아서 해줘\",",
|
|
91
|
+
"\"상관없어\", \"아무거나\"), DECIDE IT YOURSELF and move on. Never ask the same thing a third time.",
|
|
92
|
+
"Deferring to you is not permission — it means take the most conservative option:",
|
|
93
|
+
" · goes outside? → read. Nothing leaves the machine unless they said yes in their own words.",
|
|
94
|
+
" · repeat limit? → 2, the smallest useful bound.",
|
|
95
|
+
" · run time? → there is no safe time to pick, so offer to make it input-triggered instead",
|
|
96
|
+
" (it then runs only when they start it) and build that if they still do not choose.",
|
|
97
|
+
"Say what you decided for them in the goal sentence, so they can see it and change it.",
|
|
98
|
+
"",
|
|
99
|
+
"If an answer does not actually answer your question, do not repeat the question as-is —",
|
|
100
|
+
"offer concrete choices instead. Never ask more than you need: prefer building with a sensible",
|
|
101
|
+
"default over a fourth round of questions about the same thing.",
|
|
102
|
+
"",
|
|
103
|
+
"Write questions the way a helpful shop assistant would: short, concrete, one thing at a time,",
|
|
104
|
+
"with examples when a choice is not obvious. Write every question, choice, name, goal, label,",
|
|
105
|
+
"and note in the PRODUCT LANGUAGE stated at the end of this prompt — even when the person",
|
|
106
|
+
"writes in another language. The person chose the product language in settings; drifting to",
|
|
107
|
+
"the input language makes the product look broken. (Their own words quoted back are fine.)",
|
|
108
|
+
"",
|
|
109
|
+
"Return ONLY compact JSON, one of these two shapes:",
|
|
110
|
+
' {"ask":[{"id":"<stable-id>","question":"...","why":"...","choices":["...","..."]}]}',
|
|
111
|
+
` {"blueprint":{"schema":"${BLUEPRINT_SCHEMA}","name":"...","goal":"...","trigger":{...},"steps":[...],"branches":[...]}}`,
|
|
112
|
+
"",
|
|
113
|
+
'trigger is either {"kind":"cron","schedule":"daily-08:00"} (24h, or a 5-field cron string)',
|
|
114
|
+
'or {"kind":"input","label":"<what to ask the person>","varName":"<one word, a-z>"}.',
|
|
115
|
+
"",
|
|
116
|
+
'steps[] entries: {"title":"...","instruction":"...","effect":"read"|"mutation",',
|
|
117
|
+
' "produces":"<one word>","consumes":["<one word>"],"role":"<kind of worker>"}.',
|
|
118
|
+
" · instruction is what the agent is told. Write it so it can act with no further questions.",
|
|
119
|
+
" · role: what KIND of worker this step needs, in the person's language",
|
|
120
|
+
' ("한국어 마케팅 글쓰기", "web game coding", "data analysis"). Add it to every',
|
|
121
|
+
" agent/action step. Write the role, NEVER an agent name or id — the product searches",
|
|
122
|
+
" the real catalog and fills the slot itself. A name you invent does not exist and the",
|
|
123
|
+
" graph dies at run time. Steps that need the same kind of worker get the same role text.",
|
|
124
|
+
" Alongside role, add roleEn: the same role faithfully translated to English. The catalog",
|
|
125
|
+
" is English — searching with a non-English role buries the right worker (measured: the",
|
|
126
|
+
" same query ranked its target 1st in English and 144th in Korean).",
|
|
127
|
+
" · kind:\"code\" when the step is an EXACT computation or data-shaping that a chat model would",
|
|
128
|
+
" get quietly wrong: number math, currency/percent, parsing HTML/CSV/JSON, spreadsheet cells,",
|
|
129
|
+
" date arithmetic, calling a data library (e.g. yfinance). For those, add kind:\"code\", a short",
|
|
130
|
+
" codeLang (\"python\" default, or \"js\"), and code:\"<the script>\". The script gets the upstream",
|
|
131
|
+
" values as `vars` (a dict/object) and must set `result` to what the next step reads.",
|
|
132
|
+
" Read consumes[] the same way. YOU write the code — the person only describes what they want.",
|
|
133
|
+
" If the script imports anything outside the Python standard library, declare the pip names in",
|
|
134
|
+
' packages:["yfinance"] on that step — the product installs them before the run. Prefer the',
|
|
135
|
+
" standard library when it can do the job; an undeclared import dies on the user's machine.",
|
|
136
|
+
" · kind:\"agent\" (the default, omit it) for judgement, writing, summarizing, deciding — anything",
|
|
137
|
+
" where being approximately right is fine. Split a step: fetch+compute in a code step, then",
|
|
138
|
+
" judge/write in an agent step. Do not put exact math inside an agent instruction.",
|
|
139
|
+
" · a step that reads {{x}} must list x in consumes, and some earlier step (or the input trigger)",
|
|
140
|
+
' must declare produces:"x".',
|
|
141
|
+
' · effect:"mutation" for anything that leaves the machine or changes a file.',
|
|
142
|
+
' · approval:"auto" ONLY when the person explicitly said the step may go out without',
|
|
143
|
+
' their review ("검토 없이", "바로 올려", "no review needed"). Never lower it yourself,',
|
|
144
|
+
' never infer it from convenience. Omit the field otherwise — outward steps stay locked.',
|
|
145
|
+
' · uses: [{"capability":"<from the list below>","provider":"<id>"|null}] — the outside',
|
|
146
|
+
' services this step needs. Pick the capability from the closed list; if the person named a',
|
|
147
|
+
' service, put its id in provider, otherwise leave provider null and it will be asked later.',
|
|
148
|
+
' A step that only writes text needs no `uses` at all.',
|
|
149
|
+
' · Never invent a capability or provider id. If what they want is not in the list, say so',
|
|
150
|
+
' in the step instruction and leave `uses` out rather than inventing one.',
|
|
151
|
+
' · Do NOT ask whether an account is already connected, and do not mention API keys, tokens,',
|
|
152
|
+
' logins, or authentication. The product checks connections itself and asks separately.',
|
|
153
|
+
' Ask only WHICH service, and only when it genuinely changes what gets built.',
|
|
154
|
+
"",
|
|
155
|
+
'branches[] entries (optional): {"afterStep":<0-based>,"var":"<one word>",',
|
|
156
|
+
' "op":"contains|truthy|falsy|eq|ne|gt|lt","value":"...",',
|
|
157
|
+
' "yesStep":<index>,"noStep":<index>,',
|
|
158
|
+
' "repeatStep":<index>,"repeatOn":"yes"|"no","maxRepeats":<1-20>}.',
|
|
159
|
+
" · repeatStep goes BACK to an earlier step. It REQUIRES repeatOn and maxRepeats.",
|
|
160
|
+
"",
|
|
161
|
+
"checks[] (REQUIRED whenever a branch repeats, and whenever a step that changes things",
|
|
162
|
+
" outside sends out a value an earlier step computed — an unattended run must not ship",
|
|
163
|
+
" an empty or invented result):",
|
|
164
|
+
' {"afterStep":<0-based>,"subject":"<a value some step produces>",',
|
|
165
|
+
' "criteria":"<one-line summary of what passing means>","produces":"<one word>",',
|
|
166
|
+
' "items":[{"text":"<atomic, checkable>","kind":"must"|"mustNot"}]}',
|
|
167
|
+
" · A check is a SEPARATE step that judges the result against the criteria and produces",
|
|
168
|
+
' "pass" or "fail". A repeat must branch on that verdict — never on words inside the',
|
|
169
|
+
" result itself. A step that grades its own output is not a check.",
|
|
170
|
+
" · So: to repeat until good enough, add a check after the step, then branch on",
|
|
171
|
+
' {"var":"<the check\'s produces>","op":"eq","value":"fail","repeatOn":"yes",...}.',
|
|
172
|
+
" · YOU propose the checklist (items): 2-5 \"must\" items (what must exist in the result)",
|
|
173
|
+
" plus 1-3 \"mustNot\" items (common failure modes for THIS task: invented numbers,",
|
|
174
|
+
" placeholder text, copying the input verbatim, missing the asked comparison...).",
|
|
175
|
+
" Write items that are atomic and checkable — 'The CSV has a numeric price column',",
|
|
176
|
+
" not 'The data looks good'. Vague items produce noisy judging.",
|
|
177
|
+
" The person will see and can edit every item before saving — propose, don't ask.",
|
|
178
|
+
" · A factual item (\"the price matches the real value\") cannot be judged from the result",
|
|
179
|
+
" alone — the judge would guess. Split it: add a read step BEFORE the check that re-fetches",
|
|
180
|
+
" the fact (kind:\"code\" or a read step with uses) into its own produces, then set the",
|
|
181
|
+
" check's evidence:\"<that name>\". The check then compares result against evidence.",
|
|
182
|
+
" Only ask when the goal itself is too vague to know what the result even is.",
|
|
183
|
+
" · repeatOn says which side loops. Write the condition the way the person said it and",
|
|
184
|
+
" put the loop on the side they meant — do not flip either one to make it fit.",
|
|
185
|
+
"",
|
|
186
|
+
"Ask at most 3 questions per turn. Never repeat a question id you already asked.",
|
|
187
|
+
"",
|
|
188
|
+
`capability must be one of: ${CAPABILITIES.join(", ")}`,
|
|
189
|
+
`provider must be one of: ${PROVIDER_CATALOG.map((p) => p.id).join(", ")}`,
|
|
190
|
+
].join("\n");
|
|
191
|
+
|
|
192
|
+
function buildInterviewPrompt(state, locale = "ko") {
|
|
193
|
+
const known = state.answers.length
|
|
194
|
+
? state.answers.map((a) => `Q(${a.questionId}): ${a.question}\nA: ${a.answer}`).join("\n\n")
|
|
195
|
+
: "(nothing yet)";
|
|
196
|
+
const lines = [
|
|
197
|
+
RULES,
|
|
198
|
+
"",
|
|
199
|
+
`What the person asked for:\n${state.request}`,
|
|
200
|
+
"",
|
|
201
|
+
`What they have already told you:\n${known}`,
|
|
202
|
+
];
|
|
203
|
+
if (state.asked.length) {
|
|
204
|
+
lines.push("", `Question ids already asked (do not repeat): ${state.asked.join(", ")}`);
|
|
205
|
+
}
|
|
206
|
+
// ★산출 언어는 입력 언어가 아니라 **제품 설정**이 정한다(데스크탑과 같은 규칙).
|
|
207
|
+
lines.push("", `PRODUCT LANGUAGE: ${locale === "ko" ? "Korean" : "English"}. Every user-facing string you emit is in this language.`);
|
|
208
|
+
// ★지난 시도가 왜 지어지지 못했는지를 모델 앞에 놓는다. 데스크탑과 같은 규율이다 —
|
|
209
|
+
// 없으면 같은 실수를 그대로 반복한다.
|
|
210
|
+
// (패리티 게이트가 잡았다: 처음엔 이 블록이 `asked` 안에 들어가, 질문을 한 적 없는
|
|
211
|
+
// 첫 시도에서는 실패 사유가 아예 안 실렸다.)
|
|
212
|
+
const attempts = state.attempts || [];
|
|
213
|
+
if (attempts.length) {
|
|
214
|
+
lines.push(
|
|
215
|
+
"",
|
|
216
|
+
"Your previous blueprint could NOT be built. Fix exactly these problems and return a",
|
|
217
|
+
"corrected blueprint. Do not repeat the same mistake, and do not ask the person about it —",
|
|
218
|
+
"these are format problems on your side, not missing information:",
|
|
219
|
+
);
|
|
220
|
+
for (const a of attempts) for (const problem of a.problems) lines.push(` · ${problem}`);
|
|
221
|
+
}
|
|
222
|
+
if (state.round >= MAX_INTERVIEW_ROUNDS - 1) {
|
|
223
|
+
lines.push(
|
|
224
|
+
"",
|
|
225
|
+
"This is the last round. Ask only what makes the automation impossible to build without it;",
|
|
226
|
+
"otherwise return the blueprint.",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return lines.join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function triggerQuestion() {
|
|
233
|
+
return {
|
|
234
|
+
id: "trigger",
|
|
235
|
+
question: "정해진 시각에 스스로 돌까요, 값을 넣을 때만 돌까요?",
|
|
236
|
+
why: "두 방식은 서로 다른 자동화입니다. 임의로 정하면 원하지 않는 때에 돌게 됩니다.",
|
|
237
|
+
choices: ["정해진 시각에 스스로", "값을 넣을 때만"],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 청사진이 그래프로 지어질 수 있는지. 모자란 곳은 기본값이 아니라 질문으로 돌려준다. */
|
|
242
|
+
function validateBlueprint(bp) {
|
|
243
|
+
const problems = [];
|
|
244
|
+
const push = (reason, ask = null) => problems.push({ reason, ask });
|
|
245
|
+
if (!bp || typeof bp !== "object") { push("만들 내용을 읽지 못했습니다."); return problems; }
|
|
246
|
+
if (!String(bp.name || "").trim()) {
|
|
247
|
+
push("이름이 없습니다.", { id: "name", question: "이 자동화를 뭐라고 부를까요?", why: "목록에서 이 이름으로 찾게 됩니다." });
|
|
248
|
+
}
|
|
249
|
+
if (!String(bp.goal || "").trim()) {
|
|
250
|
+
push("무엇을 위한 자동화인지가 없습니다.", {
|
|
251
|
+
id: "goal", question: "이 자동화로 무엇을 얻고 싶으신가요? 한 문장이면 됩니다.",
|
|
252
|
+
why: "나중에 목록에서 보고 무엇이었는지 알아보려면 필요합니다.",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const trigger = bp.trigger;
|
|
256
|
+
if (!trigger || typeof trigger !== "object") {
|
|
257
|
+
push("언제 시작하는지가 없습니다.", triggerQuestion());
|
|
258
|
+
} else if (trigger.kind === "cron") {
|
|
259
|
+
if (!String(trigger.schedule || "").trim()) {
|
|
260
|
+
push("실행 시각이 없습니다.", {
|
|
261
|
+
id: "schedule", question: "몇 시에 돌릴까요?",
|
|
262
|
+
why: "시각을 대신 정하면, 보지 않는 시간에 조용히 돌게 됩니다.",
|
|
263
|
+
choices: ["매일 아침 8시", "매일 저녁 9시", "평일 아침 9시", "매주 월요일 아침 9시"],
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
} else if (trigger.kind === "input") {
|
|
267
|
+
if (!String(trigger.label || "").trim()) {
|
|
268
|
+
push("무엇을 입력받는지가 없습니다.", {
|
|
269
|
+
id: "input-label", question: "시작할 때 무엇을 입력받을까요? (예: 만들 프로젝트의 주제)",
|
|
270
|
+
why: "입력창에 이 문구가 그대로 보입니다.",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (!VAR_RE.test(String(trigger.varName || ""))) push("입력값의 이름이 올바르지 않습니다.");
|
|
274
|
+
} else {
|
|
275
|
+
push("언제 시작하는지를 알 수 없습니다.", triggerQuestion());
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const steps = Array.isArray(bp.steps) ? bp.steps : [];
|
|
279
|
+
if (steps.length === 0) {
|
|
280
|
+
push("할 일이 하나도 없습니다.", {
|
|
281
|
+
id: "steps", question: "무슨 일을 해야 하나요? 순서대로 적어 주세요.",
|
|
282
|
+
why: "단계가 없으면 만들 수 있는 것이 없습니다.",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (steps.length > MAX_STEPS) push(`단계가 ${steps.length}개입니다. 한 번에 만들 수 있는 것은 ${MAX_STEPS}개까지입니다.`);
|
|
286
|
+
|
|
287
|
+
const produced = new Set();
|
|
288
|
+
if (trigger && trigger.kind === "input" && trigger.varName) produced.add(trigger.varName);
|
|
289
|
+
steps.forEach((step, index) => {
|
|
290
|
+
const at = `${index + 1}번째 단계`;
|
|
291
|
+
if (!step || typeof step !== "object") { push(`${at}를 읽지 못했습니다.`); return; }
|
|
292
|
+
if (!String(step.title || "").trim()) push(`${at}에 이름이 없습니다.`);
|
|
293
|
+
if (!String(step.instruction || "").trim()) {
|
|
294
|
+
push(`${at}가 무엇을 할지 적혀 있지 않습니다.`, {
|
|
295
|
+
id: `step-${index}-instruction`,
|
|
296
|
+
question: `"${step.title || at}" 단계에서 정확히 무엇을 해야 하나요?`,
|
|
297
|
+
why: "지시가 비면 에이전트가 되물어 오고, 자동화는 아무것도 하지 못합니다.",
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (step.effect !== "read" && step.effect !== "mutation") {
|
|
301
|
+
push(`${at}가 바깥을 바꾸는지 정해지지 않았습니다.`, {
|
|
302
|
+
id: `step-${index}-effect`,
|
|
303
|
+
question: `"${step.title || at}"은(는) 바깥으로 나가는 일(글 게시, 메일 발송, 파일 저장, 결제)을 하나요?`,
|
|
304
|
+
why: "바깥을 바꾸는 단계는 실행 전에 확인받도록 잠가 둡니다.",
|
|
305
|
+
choices: ["아니요, 만들기만 합니다", "네, 바깥으로 나갑니다"],
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
for (const name of step.consumes || []) {
|
|
309
|
+
if (!produced.has(name)) {
|
|
310
|
+
push(`${at}가 쓰는 "${name}" 값을 아무도 만들지 않습니다.`, {
|
|
311
|
+
id: `step-${index}-consumes-${name}`,
|
|
312
|
+
question: `"${step.title || at}" 단계가 쓰는 "${name}"은(는) 어디서 오나요?`,
|
|
313
|
+
why: "만들어 주는 단계가 없으면 그 자리가 빈 채로 실행됩니다.",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
for (const use of step.uses || []) {
|
|
319
|
+
if (!use || typeof use !== "object") { push(`${at}의 도구 선언을 읽지 못했습니다.`); continue; }
|
|
320
|
+
if (!CAPABILITIES.includes(use.capability)) {
|
|
321
|
+
push(`${at}가 이 제품이 모르는 도구("${use.capability}")를 쓰려고 합니다.`, {
|
|
322
|
+
id: `step-${index}-capability`,
|
|
323
|
+
question: `"${step.title || at}" 단계는 어떤 서비스를 씁니까?`,
|
|
324
|
+
why: "이 제품이 다룰 수 있는 것으로 골라야 실제로 연결할 수 있습니다.",
|
|
325
|
+
choices: CAPABILITY_CHOICES,
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (use.provider && !findProvider(use.provider)) {
|
|
330
|
+
push(`${at}가 이 제품이 모르는 서비스("${use.provider}")를 가리킵니다.`, {
|
|
331
|
+
id: `step-${index}-provider`,
|
|
332
|
+
question: `"${step.title || at}" 단계는 어느 서비스를 씁니까?`,
|
|
333
|
+
why: "서비스가 정해져야 어느 계정을 연결할지 알 수 있습니다.",
|
|
334
|
+
choices: providersFor(use.capability).map((p) => p.label),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (step.produces) {
|
|
339
|
+
if (!VAR_RE.test(step.produces)) push(`${at}의 결과 이름 "${step.produces}"은(는) 쓸 수 없습니다.`);
|
|
340
|
+
else produced.add(step.produces);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
// 검증 단계
|
|
346
|
+
const checkVerdicts = new Set();
|
|
347
|
+
for (const check of bp.checks || []) {
|
|
348
|
+
const at = `${(check.afterStep ?? 0) + 1}번째 단계 뒤의 검증`;
|
|
349
|
+
if (!steps[check.afterStep]) { push(`${at}가 없는 단계를 가리킵니다.`); continue; }
|
|
350
|
+
if (!check.subject || !produced.has(check.subject)) {
|
|
351
|
+
push(`${at}가 볼 "${check.subject}" 값을 아무도 만들지 않습니다.`);
|
|
352
|
+
}
|
|
353
|
+
const checkItems = Array.isArray(check.items)
|
|
354
|
+
? check.items.filter((item) => item && typeof item.text === "string" && item.text.trim())
|
|
355
|
+
: [];
|
|
356
|
+
for (const item of Array.isArray(check.items) ? check.items : []) {
|
|
357
|
+
if (!item || typeof item.text !== "string" || !item.text.trim()) {
|
|
358
|
+
push(`${at}의 채점표 항목 하나가 비어 있습니다.`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (checkItems.length === 0 && !String(check.criteria || "").trim()) {
|
|
362
|
+
push(`${at}의 통과 기준이 없습니다.`, {
|
|
363
|
+
id: `check-${check.afterStep}-criteria`,
|
|
364
|
+
question: `"${(steps[check.afterStep] && steps[check.afterStep].title) || at}" 결과가 어떤 상태여야 통과인가요?`,
|
|
365
|
+
why: "기준이 없으면 무엇을 보고 판정할지 정할 수 없습니다.",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
if (check.evidence && !produced.has(check.evidence)) {
|
|
369
|
+
push(`${at}가 근거로 삼는 "${check.evidence}" 값을 아무도 만들지 않습니다.`, {
|
|
370
|
+
id: `check-${check.afterStep}-evidence`,
|
|
371
|
+
question: `검증 근거 "${check.evidence}"은(는) 어느 단계가 가져오나요?`,
|
|
372
|
+
why: "근거 없는 사실 확인은 판정자가 지어내게 됩니다 — 재조회 단계가 먼저 필요합니다.",
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
const name = String(check.produces || "").trim() || `check${check.afterStep + 1}_verdict`;
|
|
376
|
+
produced.add(name);
|
|
377
|
+
checkVerdicts.add(name);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/*
|
|
381
|
+
* ★계산한 값이 그대로 **바깥으로 나가면** 검증이 있어야 한다(데스크탑과 같은 규칙).
|
|
382
|
+
* 실사용 실측(2026-08-06, 주간 매출 요약): 증감률이 전부 null인데 아무도 안 보고
|
|
383
|
+
* 요약 엑셀로 저장될 뻔했다 — 검증을 "반복이 있을 때만" 요구했기 때문이다.
|
|
384
|
+
*/
|
|
385
|
+
{
|
|
386
|
+
const checkedSubjects = new Set(
|
|
387
|
+
(bp.checks || []).map((check) => (check.subject || "").trim()).filter(Boolean),
|
|
388
|
+
);
|
|
389
|
+
steps.forEach((step, index) => {
|
|
390
|
+
if (step.effect !== "mutation") return;
|
|
391
|
+
const consumes = Array.isArray(step.consumes) ? step.consumes : [];
|
|
392
|
+
for (const value of consumes) {
|
|
393
|
+
const name = String(value == null ? "" : value).trim();
|
|
394
|
+
if (!name || checkedSubjects.has(name)) continue;
|
|
395
|
+
const madeByAStep = steps.some((s, i) => i < index && (s.produces || "").trim() === name);
|
|
396
|
+
if (!madeByAStep) continue;
|
|
397
|
+
push(
|
|
398
|
+
`"${step.title || `${index + 1}번째 단계`}"는 바깥으로 나가는데, 그 앞에서 만든 `
|
|
399
|
+
+ `"${name}" 값이 쓸 만한지 확인하는 단계가 없습니다. `
|
|
400
|
+
+ `checks[]에 {"afterStep":<그 값을 만든 단계>,"subject":"${name}",…}를 넣어 주세요.`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 반복이 있는데 검증이 없으면 "마음에 들 때까지"를 글자 찾기로 흉내 내게 된다(실측).
|
|
407
|
+
for (const branch of bp.branches || []) {
|
|
408
|
+
if (branch.repeatStep === undefined) continue;
|
|
409
|
+
if (!checkVerdicts.has(branch.var)) {
|
|
410
|
+
push(`${(branch.afterStep ?? 0) + 1}번째 단계 뒤의 반복이 검증 결과가 아니라 "${branch.var}"의 내용을 보고 돌지 말지 정합니다.`, {
|
|
411
|
+
id: `branch-${branch.afterStep}-needs-check`,
|
|
412
|
+
question: `"${(steps[branch.repeatStep] && steps[branch.repeatStep].title) || "앞 단계"}"를 다시 할지 말지, 무엇을 보고 정할까요? 통과 기준을 한 문장으로 적어 주세요.`,
|
|
413
|
+
why: "만든 단계가 자기 결과에 붙인 글자를 보고 정하면, 자기가 자기를 채점하는 셈이 됩니다.",
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
for (const branch of bp.branches || []) {
|
|
419
|
+
const at = `${(branch.afterStep ?? 0) + 1}번째 단계 뒤의 갈림길`;
|
|
420
|
+
if (!steps[branch.afterStep]) { push(`${at}가 없는 단계를 가리킵니다.`); continue; }
|
|
421
|
+
if (!OPS.has(branch.op)) { push(`${at}의 판단 방법을 알 수 없습니다.`); continue; }
|
|
422
|
+
if (!branch.var || !produced.has(branch.var)) push(`${at}가 보는 "${branch.var}" 값을 아무도 만들지 않습니다.`);
|
|
423
|
+
if (VALUE_OPS.has(branch.op) && (branch.value === undefined || branch.value === null || branch.value === "")) {
|
|
424
|
+
push(`${at}가 무엇과 비교하는지 정해져 있지 않습니다.`, {
|
|
425
|
+
id: `branch-${branch.afterStep}-value`,
|
|
426
|
+
question: `${at}에서, 어떤 경우에 "예"로 갈까요?`,
|
|
427
|
+
why: "비교할 것이 없으면 갈림길이 판단하지 못하고 거기서 멈춥니다.",
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// 앞 단계로 가는 연결은 이름이 무엇이든 **반복**이다. 상한이 없으면 커널이 실행을 거절한다.
|
|
431
|
+
for (const pair of [["yesStep", branch.yesStep], ["noStep", branch.noStep]]) {
|
|
432
|
+
const target = pair[1];
|
|
433
|
+
if (typeof target !== "number") continue;
|
|
434
|
+
if (target <= branch.afterStep && branch.repeatStep === undefined) {
|
|
435
|
+
push(`${at}의 "${pair[0] === "yesStep" ? "예" : "아니오"}" 쪽이 앞 단계로 되돌아가는데 반복 횟수가 없습니다.`, {
|
|
436
|
+
id: `branch-${branch.afterStep}-repeats`,
|
|
437
|
+
question: `${at}에서 되돌아가는 반복, 최대 몇 번까지 할까요?`,
|
|
438
|
+
why: "사람이 보지 않는 사이에 도는 자동화라, 멈출 지점이 없으면 실행하지 않습니다.",
|
|
439
|
+
choices: ["2번", "3번", "5번"],
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (branch.repeatStep !== undefined) {
|
|
444
|
+
if (branch.repeatOn !== "yes" && branch.repeatOn !== "no") {
|
|
445
|
+
// 막기만 하면 인터뷰가 막다른 길이 된다. 방향은 사람이 말해야 하는 것이다(실측 3/3 뒤집힘).
|
|
446
|
+
const back = (steps[branch.repeatStep] && steps[branch.repeatStep].title) || "앞 단계";
|
|
447
|
+
const rule = branchLabel(branch);
|
|
448
|
+
push(`${at}가 어느 쪽으로 갈 때 되돌아가는지 정해지지 않았습니다.`, {
|
|
449
|
+
id: `branch-${branch.afterStep}-direction`,
|
|
450
|
+
question: `"${rule}" — 어느 쪽일 때 "${back}"부터 다시 할까요?`,
|
|
451
|
+
why: "이 방향이 뒤집히면 원하는 것과 정반대로 도는 자동화가 됩니다.",
|
|
452
|
+
choices: ["그렇다면 다시", "아니라면 다시"],
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (!steps[branch.repeatStep]) push(`${at}의 되돌아갈 단계가 없습니다.`);
|
|
456
|
+
if (branch.repeatStep > branch.afterStep) push(`${at}는 뒤쪽 단계로 되돌아갈 수 없습니다.`);
|
|
457
|
+
if (branch.repeatOn === "yes" ? branch.noStep === branch.repeatStep : branch.yesStep === branch.repeatStep) {
|
|
458
|
+
push(`${at}의 양쪽이 모두 같은 단계로 갑니다 — 갈림길이 아무것도 가르지 않습니다.`);
|
|
459
|
+
}
|
|
460
|
+
const cap = branch.maxRepeats;
|
|
461
|
+
if (typeof cap !== "number" || !Number.isFinite(cap) || cap < 1 || cap > MAX_REPEATS) {
|
|
462
|
+
push(`${at}의 반복 횟수가 정해져 있지 않습니다.`, {
|
|
463
|
+
id: `branch-${branch.afterStep}-repeats`,
|
|
464
|
+
question: `${at}에서 되돌아가는 반복, 최대 몇 번까지 할까요?`,
|
|
465
|
+
why: "사람이 보지 않는 사이에 도는 자동화라, 멈출 지점이 없으면 실행하지 않습니다.",
|
|
466
|
+
choices: ["2번", "3번", "5번"],
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (branch.yesStep === undefined && branch.noStep === undefined && branch.repeatStep === undefined) {
|
|
471
|
+
push(`${at} 뒤에 아무것도 이어져 있지 않습니다.`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return problems;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 갈림길이 실제로 어떻게 갈라지는지 사람 말로. 저장 전에 이걸로 확인을 받는다.
|
|
480
|
+
* 실측: 만들어진 갈림길 3개가 전부 방향이 거꾸로였는데 그림을 안 보면 알 수 없었다.
|
|
481
|
+
*/
|
|
482
|
+
function describeBranches(bp, locale) {
|
|
483
|
+
const ko = locale !== "en";
|
|
484
|
+
const lines = [];
|
|
485
|
+
const title = (index) => (typeof index === "number" && bp.steps[index] ? bp.steps[index].title : (ko ? "끝" : "the end"));
|
|
486
|
+
for (const branch of bp.branches || []) {
|
|
487
|
+
const rule = branchLabel(branch);
|
|
488
|
+
const repeatText = branch.repeatStep !== undefined
|
|
489
|
+
? (ko ? `"${title(branch.repeatStep)}"부터 다시 (최대 ${branch.maxRepeats}번)` : `back to "${title(branch.repeatStep)}" (up to ${branch.maxRepeats}x)`)
|
|
490
|
+
: null;
|
|
491
|
+
const yes = repeatText && branch.repeatOn === "yes"
|
|
492
|
+
? repeatText
|
|
493
|
+
: branch.yesStep !== undefined ? title(branch.yesStep) : title(branch.afterStep + 1);
|
|
494
|
+
const no = repeatText && branch.repeatOn !== "yes"
|
|
495
|
+
? repeatText
|
|
496
|
+
: branch.noStep !== undefined ? title(branch.noStep) : title(branch.afterStep + 1);
|
|
497
|
+
const quote = (text) => (String(text).charAt(0) === '"' ? String(text) : `"${text}"`);
|
|
498
|
+
lines.push(ko
|
|
499
|
+
? `${rule} → 그렇다면 ${quote(yes || "끝")}, 아니라면 ${quote(no)}`
|
|
500
|
+
: `${rule} → yes: ${quote(yes || "the end")}, no: ${quote(no)}`);
|
|
501
|
+
}
|
|
502
|
+
return lines;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function branchLabel(branch) {
|
|
506
|
+
const shown = typeof branch.value === "string" ? `"${branch.value}"` : String(branch.value ?? "");
|
|
507
|
+
switch (branch.op) {
|
|
508
|
+
case "contains": return `${branch.var}에 ${shown}이(가) 있나?`;
|
|
509
|
+
case "truthy": return `${branch.var}에 값이 있나?`;
|
|
510
|
+
case "falsy": return `${branch.var}이(가) 비었나?`;
|
|
511
|
+
case "eq": return `${branch.var}이(가) ${shown}인가?`;
|
|
512
|
+
case "ne": return `${branch.var}이(가) ${shown}이 아닌가?`;
|
|
513
|
+
case "gt": return `${branch.var}이(가) ${shown}보다 큰가?`;
|
|
514
|
+
case "lt": return `${branch.var}이(가) ${shown}보다 작은가?`;
|
|
515
|
+
default: return `${branch.var} 확인`;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 실행 시점을 사람 말로. `0 8 * * 1-5`나 `daily-08:00`은 저장 형식이지 사람이 읽을 말이 아니다.
|
|
521
|
+
* 데스크탑 shared/graph-blueprint.ts 의 humanSchedule 과 같은 규칙이어야 한다.
|
|
522
|
+
*/
|
|
523
|
+
function humanSchedule(schedule, locale) {
|
|
524
|
+
const ko = locale !== "en";
|
|
525
|
+
const raw = String(schedule == null ? "" : schedule).trim();
|
|
526
|
+
if (!raw || raw === "manual") return ko ? "값을 넣을 때만" : "only when you start it";
|
|
527
|
+
const daily = /^daily-(\d{2}):(\d{2})$/.exec(raw);
|
|
528
|
+
if (daily) return ko ? `매일 ${hhmm(daily[1], daily[2], "ko")}` : `every day at ${daily[1]}:${daily[2]}`;
|
|
529
|
+
const parts = raw.split(/\s+/);
|
|
530
|
+
if (parts.length === 5) {
|
|
531
|
+
const min = parts[0], hour = parts[1], dom = parts[2], mon = parts[3], dow = parts[4];
|
|
532
|
+
if (/^\d+$/.test(min) && /^\d+$/.test(hour) && mon === "*") {
|
|
533
|
+
const at = hhmm(String(hour).padStart(2, "0"), String(min).padStart(2, "0"), ko ? "ko" : "en");
|
|
534
|
+
const when = dowPhrase(dow, dom, ko ? "ko" : "en");
|
|
535
|
+
return ko ? `${when} ${at}` : `${when} at ${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return raw;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function hhmm(hour, minute, locale) {
|
|
542
|
+
if (locale !== "ko") return `${hour}:${minute}`;
|
|
543
|
+
const h = Number(hour);
|
|
544
|
+
const period = h < 12 ? "오전" : "오후";
|
|
545
|
+
const shown = h % 12 === 0 ? 12 : h % 12;
|
|
546
|
+
return minute === "00" ? `${period} ${shown}시` : `${period} ${shown}시 ${Number(minute)}분`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const DOW_KO = { "0": "일", "1": "월", "2": "화", "3": "수", "4": "목", "5": "금", "6": "토", "7": "일" };
|
|
550
|
+
|
|
551
|
+
function dowPhrase(dow, dom, locale) {
|
|
552
|
+
const ko = locale === "ko";
|
|
553
|
+
if (dow === "*" && dom === "*") return ko ? "매일" : "every day";
|
|
554
|
+
if (dow === "1-5") return ko ? "평일(월~금)" : "every weekday";
|
|
555
|
+
if (dow === "0,6" || dow === "6,0") return ko ? "주말" : "every weekend";
|
|
556
|
+
if (/^\d$/.test(dow)) return ko ? `매주 ${DOW_KO[dow]}요일` : `every week on day ${dow}`;
|
|
557
|
+
if (dow === "*" && /^\d+$/.test(dom)) return ko ? `매월 ${Number(dom)}일` : `on day ${dom} of each month`;
|
|
558
|
+
if (/^[\d,]+$/.test(dow)) {
|
|
559
|
+
const days = dow.split(",").map((d) => DOW_KO[d] || d).join("·");
|
|
560
|
+
return ko ? `매주 ${days}요일` : `on ${dow}`;
|
|
561
|
+
}
|
|
562
|
+
return ko ? "정해진 때" : "on schedule";
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function scheduleLabel(schedule) {
|
|
566
|
+
return humanSchedule(schedule, "ko");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** 청사진 → 그래프. **노드 id와 연결은 전부 여기서 만든다.** */
|
|
570
|
+
/** 길이를 넘으면 마지막 온전한 낱말까지만 — 데스크탑 clipAtWord와 같은 규칙. */
|
|
571
|
+
function clipAtWord(text, max) {
|
|
572
|
+
if (text.length <= max) return text;
|
|
573
|
+
const cut = text.slice(0, max);
|
|
574
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
575
|
+
const body = lastSpace > max * 0.5 ? cut.slice(0, lastSpace) : cut;
|
|
576
|
+
return `${body.trimEnd()}…`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function buildGraphFromBlueprint(bp, locale = "ko") {
|
|
580
|
+
const problems = validateBlueprint(bp);
|
|
581
|
+
if (problems.length) return { ok: false, problems };
|
|
582
|
+
|
|
583
|
+
const nodes = [];
|
|
584
|
+
const edges = [];
|
|
585
|
+
const column = (i) => i * 280;
|
|
586
|
+
const trigger = bp.trigger;
|
|
587
|
+
nodes.push({
|
|
588
|
+
id: "start", type: "trigger",
|
|
589
|
+
label: trigger.kind === "cron" ? scheduleLabel(trigger.schedule) : trigger.label,
|
|
590
|
+
position: { x: 0, y: 0 },
|
|
591
|
+
config: trigger.kind === "cron"
|
|
592
|
+
? { schedule: trigger.schedule }
|
|
593
|
+
: { kind: "input", promptLabel: trigger.label, produces: trigger.varName },
|
|
594
|
+
});
|
|
595
|
+
const stepId = (i) => `step${i + 1}`;
|
|
596
|
+
bp.steps.forEach((step, index) => {
|
|
597
|
+
const isCode = step.kind === "code";
|
|
598
|
+
nodes.push({
|
|
599
|
+
id: stepId(index),
|
|
600
|
+
// 코드 스텝은 code 노드로(데스크탑 shared/graph-blueprint.ts와 같은 규칙).
|
|
601
|
+
type: isCode ? "code" : (step.effect === "mutation" ? "action" : "agent"),
|
|
602
|
+
label: step.title,
|
|
603
|
+
position: { x: column(index + 1), y: 0 },
|
|
604
|
+
config: {
|
|
605
|
+
...(isCode
|
|
606
|
+
? {
|
|
607
|
+
code: step.code || "", codeLang: step.codeLang === "js" ? "js" : "python", note: step.instruction,
|
|
608
|
+
...(Array.isArray(step.packages) && step.packages.length
|
|
609
|
+
? { packages: step.packages.map((v) => String(v).trim()).filter(Boolean) }
|
|
610
|
+
: {}),
|
|
611
|
+
}
|
|
612
|
+
: { prompt: step.instruction }),
|
|
613
|
+
effect: step.effect,
|
|
614
|
+
// 기본은 잠김. 사람이 명시로 "검토 없이"라고 했을 때만 auto(데스크탑과 같은 규칙).
|
|
615
|
+
...(step.effect === "mutation"
|
|
616
|
+
? { approval: step.approval === "auto" ? "auto" : "ask" }
|
|
617
|
+
: {}),
|
|
618
|
+
// ★역할은 저장돼야 한다 — 묻기만 하고 버리면 편성이 채울 슬롯 자체가 없다
|
|
619
|
+
// (데스크탑 shared/graph-blueprint.ts와 같은 자리, 같은 규칙).
|
|
620
|
+
...(typeof step.role === "string" && step.role.trim() ? { role: step.role.trim() } : {}),
|
|
621
|
+
...(typeof step.roleEn === "string" && step.roleEn.trim() ? { roleEn: step.roleEn.trim() } : {}),
|
|
622
|
+
...(step.produces ? { produces: step.produces } : {}),
|
|
623
|
+
...(step.consumes && step.consumes.length ? { consumes: step.consumes[0] } : {}),
|
|
624
|
+
...(step.uses && step.uses.length
|
|
625
|
+
? { needs: step.uses.map((use) => ({
|
|
626
|
+
capability: use.capability,
|
|
627
|
+
provider: use.provider && findProvider(use.provider) ? use.provider : null,
|
|
628
|
+
required: true,
|
|
629
|
+
})) }
|
|
630
|
+
: {}),
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
const branchAt = new Map();
|
|
636
|
+
for (const branch of bp.branches || []) branchAt.set(branch.afterStep, branch);
|
|
637
|
+
// ★한 단계 뒤에 검증 여럿(데스크탑 shared/graph-blueprint.ts와 같은 규칙).
|
|
638
|
+
const checkAt = new Map();
|
|
639
|
+
for (const check of bp.checks || []) {
|
|
640
|
+
const list = checkAt.get(check.afterStep) || [];
|
|
641
|
+
list.push(check);
|
|
642
|
+
checkAt.set(check.afterStep, list);
|
|
643
|
+
}
|
|
644
|
+
const checkId = (i, ordinal = 0) =>
|
|
645
|
+
ordinal === 0 ? `verify${i + 1}` : `verify${i + 1}-${ordinal + 1}`;
|
|
646
|
+
let seq = 0;
|
|
647
|
+
const link = (source, target, handle, maxIterations) => {
|
|
648
|
+
edges.push({
|
|
649
|
+
id: `e${seq += 1}`, source, target,
|
|
650
|
+
...(handle ? { sourceHandle: handle } : {}),
|
|
651
|
+
...(typeof maxIterations === "number" ? { maxIterations } : {}),
|
|
652
|
+
});
|
|
653
|
+
};
|
|
654
|
+
link("start", stepId(0));
|
|
655
|
+
for (const [afterStep, list] of checkAt) {
|
|
656
|
+
if (!bp.steps[afterStep]) continue;
|
|
657
|
+
list.forEach((check, ordinal) => {
|
|
658
|
+
const firstItem = Array.isArray(check.items)
|
|
659
|
+
? check.items.find((item) => item && typeof item.text === "string" && item.text.trim())
|
|
660
|
+
: null;
|
|
661
|
+
// ★접두어는 제품 언어를 따르고, 자를 때 낱말을 쪼개지 않는다(데스크탑과 같은 규칙).
|
|
662
|
+
const rawLabel = String((check.criteria || "").trim() || (firstItem && firstItem.text)
|
|
663
|
+
|| (locale === "en" ? "Checklist" : "채점표")).trim();
|
|
664
|
+
const label = clipAtWord(rawLabel, 40);
|
|
665
|
+
const itemRows = Array.isArray(check.items)
|
|
666
|
+
? check.items
|
|
667
|
+
.filter((item) => item && typeof item.text === "string" && item.text.trim())
|
|
668
|
+
.map((item) => ({ text: item.text.trim(), kind: item.kind === "mustNot" ? "mustNot" : "must" }))
|
|
669
|
+
: [];
|
|
670
|
+
nodes.push({
|
|
671
|
+
id: checkId(afterStep, ordinal), type: "eval",
|
|
672
|
+
label: `${locale === "en" ? "Check" : "검증"}: ${label}`,
|
|
673
|
+
position: { x: column(afterStep + 1) + 70 + ordinal * 60, y: 0 },
|
|
674
|
+
config: {
|
|
675
|
+
subject: check.subject,
|
|
676
|
+
...(String(check.criteria || "").trim() ? { criteria: check.criteria } : {}),
|
|
677
|
+
...(itemRows.length ? { items: itemRows } : {}),
|
|
678
|
+
...(typeof check.evidence === "string" && check.evidence.trim() ? { evidence: check.evidence.trim() } : {}),
|
|
679
|
+
produces: String(check.produces || "").trim()
|
|
680
|
+
|| (ordinal === 0 ? `check${afterStep + 1}_verdict` : `check${afterStep + 1}_${ordinal + 1}_verdict`),
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
bp.steps.forEach((_step, index) => {
|
|
686
|
+
const checkList = checkAt.get(index) || [];
|
|
687
|
+
const branch = branchAt.get(index);
|
|
688
|
+
const afterStepId = checkList.length ? checkId(index, checkList.length - 1) : stepId(index);
|
|
689
|
+
checkList.forEach((_check, ordinal) => {
|
|
690
|
+
link(ordinal === 0 ? stepId(index) : checkId(index, ordinal - 1), checkId(index, ordinal));
|
|
691
|
+
});
|
|
692
|
+
if (!branch) {
|
|
693
|
+
if (bp.steps[index + 1]) link(afterStepId, stepId(index + 1));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const branchId = `check${index + 1}`;
|
|
697
|
+
nodes.push({
|
|
698
|
+
id: branchId, type: "condition", label: branchLabel(branch),
|
|
699
|
+
position: { x: column(index + 1) + 140, y: 0 },
|
|
700
|
+
config: { var: branch.var, op: branch.op, ...(branch.value !== undefined ? { value: branch.value } : {}) },
|
|
701
|
+
});
|
|
702
|
+
link(afterStepId, branchId);
|
|
703
|
+
// 되돌아가는 쪽은 선언(repeatOn)대로 잇는다 — 거짓 쪽으로 고정하면 사람이 말한
|
|
704
|
+
// 방향과 반대인 자동화가 만들어진다(실측 3/3).
|
|
705
|
+
const repeatSide = branch.repeatStep !== undefined ? branch.repeatOn : undefined;
|
|
706
|
+
if (repeatSide === "yes") link(branchId, stepId(branch.repeatStep), "true", branch.maxRepeats);
|
|
707
|
+
else if (branch.yesStep !== undefined && bp.steps[branch.yesStep]) link(branchId, stepId(branch.yesStep), "true");
|
|
708
|
+
else if (bp.steps[index + 1]) link(branchId, stepId(index + 1), "true");
|
|
709
|
+
if (repeatSide === "no") link(branchId, stepId(branch.repeatStep), "false", branch.maxRepeats);
|
|
710
|
+
else if (branch.noStep !== undefined && bp.steps[branch.noStep]) link(branchId, stepId(branch.noStep), "false");
|
|
711
|
+
else if (repeatSide === "yes" && bp.steps[index + 1]) link(branchId, stepId(index + 1), "false");
|
|
712
|
+
// ★빠져나가는 쪽이 비어 있으면 끝나는 자리를 만들어 준다 (데스크탑 shared/graph-blueprint.ts와 같은 규칙).
|
|
713
|
+
// "마음에 들 때까지 다시 써"를 마지막 단계에 걸면 되돌아가는 쪽만 이어지고 빠져나가는 쪽이 빈다.
|
|
714
|
+
// 그러면 커널은 NO_MATCHING_EDGE로 멈춘다 — **드디어 통과한 순간에**. 실패하는 동안은 잘 돌다가
|
|
715
|
+
// 성공하자마자 죽는 가장 나쁜 타이밍이고, 말로 만든 사람은 뭘 빠뜨렸는지 알 수도 없다.
|
|
716
|
+
const exitSide = repeatSide === "yes" ? "false" : repeatSide === "no" ? "true" : null;
|
|
717
|
+
if (exitSide && !edges.some((e) => e.source === branchId && e.sourceHandle === exitSide)) {
|
|
718
|
+
const doneId = `${branchId}-done`;
|
|
719
|
+
const produced = bp.steps[branch.repeatStep !== undefined ? branch.repeatStep : index]
|
|
720
|
+
&& bp.steps[branch.repeatStep !== undefined ? branch.repeatStep : index].produces;
|
|
721
|
+
nodes.push({
|
|
722
|
+
id: doneId,
|
|
723
|
+
type: "output",
|
|
724
|
+
position: { x: 0, y: 0 },
|
|
725
|
+
label: "끝",
|
|
726
|
+
config: { effect: "read", text: produced ? `{{${produced}}}` : "완료했습니다." },
|
|
727
|
+
});
|
|
728
|
+
link(branchId, doneId, exitSide);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// ★겹치지 않게 배치한 뒤 돌려준다(데스크탑 shared/graph-blueprint.ts와 같은 규칙·같은 상수).
|
|
733
|
+
const built = { version: 1, nodes, edges };
|
|
734
|
+
const laidOut = needsLayout(built) ? layoutGraph(built) : nodes;
|
|
735
|
+
return {
|
|
736
|
+
ok: true,
|
|
737
|
+
graph: { version: 1, nodes: laidOut, edges },
|
|
738
|
+
scheduleHuman: trigger.kind === "cron" ? trigger.schedule : "manual",
|
|
739
|
+
triggerType: trigger.kind === "cron" ? "schedule" : "manual",
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function firstJsonObject(text) {
|
|
744
|
+
const trimmed = String(text ?? "").trim();
|
|
745
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
746
|
+
const body = fenced ? fenced[1].trim() : trimmed;
|
|
747
|
+
const start = body.indexOf("{");
|
|
748
|
+
if (start < 0) return null;
|
|
749
|
+
let depth = 0, inString = false, escaped = false;
|
|
750
|
+
for (let i = start; i < body.length; i += 1) {
|
|
751
|
+
const ch = body[i];
|
|
752
|
+
if (inString) {
|
|
753
|
+
if (escaped) escaped = false;
|
|
754
|
+
else if (ch === "\\") escaped = true;
|
|
755
|
+
else if (ch === '"') inString = false;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (ch === '"') inString = true;
|
|
759
|
+
else if (ch === "{") depth += 1;
|
|
760
|
+
else if (ch === "}") { depth -= 1; if (depth === 0) return body.slice(start, i + 1); }
|
|
761
|
+
}
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const unreadable = () => ({
|
|
766
|
+
ok: false, code: "INTERVIEW_OUTPUT_UNREADABLE",
|
|
767
|
+
reason: "만들 내용을 읽지 못했습니다.",
|
|
768
|
+
nextAction: "자동으로 돌릴 일을 한 문장으로 다시 적어 주세요.",
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
function normalizeQuestions(candidates, state) {
|
|
772
|
+
const seen = new Set(state.asked);
|
|
773
|
+
const out = [];
|
|
774
|
+
for (const candidate of candidates) {
|
|
775
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
776
|
+
const question = typeof candidate.question === "string" ? candidate.question.trim() : "";
|
|
777
|
+
if (!question) continue;
|
|
778
|
+
const id = String(candidate.id && String(candidate.id).trim() ? candidate.id : question).slice(0, 80);
|
|
779
|
+
if (seen.has(id)) continue;
|
|
780
|
+
seen.add(id);
|
|
781
|
+
out.push({
|
|
782
|
+
id,
|
|
783
|
+
question: question.slice(0, 300),
|
|
784
|
+
why: (typeof candidate.why === "string" ? candidate.why.trim() : "").slice(0, 300),
|
|
785
|
+
...(Array.isArray(candidate.choices)
|
|
786
|
+
? { choices: candidate.choices.filter((c) => typeof c === "string").slice(0, 6) }
|
|
787
|
+
: {}),
|
|
788
|
+
});
|
|
789
|
+
if (out.length >= MAX_QUESTIONS_PER_TURN) break;
|
|
790
|
+
}
|
|
791
|
+
return out;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* 모델 출력을 읽는다.
|
|
796
|
+
* ★핵심: 모델이 blueprint를 냈더라도 **검증을 통과하지 못하면 질문으로 되돌린다.**
|
|
797
|
+
*/
|
|
798
|
+
/**
|
|
799
|
+
* 다시 만든 청사진이 앞 시도보다 작아졌는가. 데스크탑과 같은 계약.
|
|
800
|
+
* 프롬프트로 "지우지 마라"고 부탁하는 것만으로는 안 된다 — 검증 오류는 그 단계를
|
|
801
|
+
* 지우면 사라지고, 지워진 청사진은 검증을 통과한다.
|
|
802
|
+
*/
|
|
803
|
+
function weakenedAgainstLastAttempt(blueprint, state) {
|
|
804
|
+
const attempts = (state && state.attempts) || [];
|
|
805
|
+
const last = attempts[attempts.length - 1];
|
|
806
|
+
if (!last) return null;
|
|
807
|
+
const steps = Array.isArray(blueprint.steps) ? blueprint.steps.length : 0;
|
|
808
|
+
const complainedAboutSize = (last.problems || []).some((p) => p.includes("단계가") && p.includes("개입니다"));
|
|
809
|
+
if (typeof last.stepCount === "number" && steps < last.stepCount && !complainedAboutSize) {
|
|
810
|
+
return `앞서 만든 것에는 단계가 ${last.stepCount}개였는데 이번에는 ${steps}개입니다.`
|
|
811
|
+
+ " 문제를 그 단계를 지워서 고치면, 부탁하신 일이 사라진 채로 만들어집니다.";
|
|
812
|
+
}
|
|
813
|
+
const trigger = blueprint.trigger && blueprint.trigger.kind;
|
|
814
|
+
if (last.triggerKind && trigger && trigger !== last.triggerKind) {
|
|
815
|
+
return `시작 방식이 "${last.triggerKind}"에서 "${trigger}"로 바뀌었습니다.`
|
|
816
|
+
+ " 언제 시작할지는 말씀하신 대로 두어야 합니다.";
|
|
817
|
+
}
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function parseInterviewTurn(text, state) {
|
|
822
|
+
const raw = firstJsonObject(text);
|
|
823
|
+
if (!raw) return unreadable();
|
|
824
|
+
let parsed;
|
|
825
|
+
try { parsed = JSON.parse(raw); } catch { return unreadable(); }
|
|
826
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return unreadable();
|
|
827
|
+
|
|
828
|
+
if (Array.isArray(parsed.ask) && parsed.ask.length > 0) {
|
|
829
|
+
const questions = normalizeQuestions(parsed.ask, state);
|
|
830
|
+
if (!questions.length) {
|
|
831
|
+
return {
|
|
832
|
+
ok: false, code: "INTERVIEW_REPEATED_QUESTIONS",
|
|
833
|
+
reason: "이미 답하신 것만 다시 물으려 했습니다.",
|
|
834
|
+
nextAction: "다시 시도하거나, 만들 것을 조금 더 구체적으로 적어 주세요.",
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
return { ok: true, turn: { kind: "ask", questions } };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const blueprint = parsed.blueprint;
|
|
841
|
+
if (!blueprint || typeof blueprint !== "object") return unreadable();
|
|
842
|
+
const normalized = { ...blueprint, schema: BLUEPRINT_SCHEMA };
|
|
843
|
+
const problems = validateBlueprint(normalized);
|
|
844
|
+
if (problems.length === 0) {
|
|
845
|
+
// 검증은 통과했다. 그런데 **앞 시도보다 작아졌으면** 문제를 지워서 고친 것이다 —
|
|
846
|
+
// 지워진 청사진은 검증을 통과하고, 사람이 부탁한 일이 사라진 채로 만들어진다.
|
|
847
|
+
const weakened = weakenedAgainstLastAttempt(normalized, state);
|
|
848
|
+
if (weakened) return { ok: true, turn: { kind: "retry", problems: [weakened] } };
|
|
849
|
+
return { ok: true, turn: { kind: "blueprint", blueprint: normalized } };
|
|
850
|
+
}
|
|
851
|
+
const questions = normalizeQuestions(problems.map((p) => p.ask).filter(Boolean), state);
|
|
852
|
+
if (questions.length) return { ok: true, turn: { kind: "ask", questions } };
|
|
853
|
+
// 물어서 채울 수 없는 문제 — 사람이 답을 안 준 게 아니라 **모델이 형식을 틀린** 것이다.
|
|
854
|
+
// 그걸 "구체적으로 적어 주세요"로 떠넘기면 막다른 길이 된다: 무엇이 틀렸는지 사람은
|
|
855
|
+
// 모르고, 우리는 안다. 무엇이 틀렸는지 돌려주고 스스로 고치게 한다.
|
|
856
|
+
return {
|
|
857
|
+
ok: true,
|
|
858
|
+
turn: {
|
|
859
|
+
kind: "retry",
|
|
860
|
+
problems: problems.map((p) => p.reason),
|
|
861
|
+
stepCount: Array.isArray(normalized.steps) ? normalized.steps.length : 0,
|
|
862
|
+
triggerKind: normalized.trigger && normalized.trigger.kind,
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** 모델이 스스로 고쳐 볼 기회의 상한. 데스크탑과 같은 값. */
|
|
868
|
+
const MAX_SELF_CORRECTIONS = 2;
|
|
869
|
+
|
|
870
|
+
module.exports = {
|
|
871
|
+
BLUEPRINT_SCHEMA, MAX_QUESTIONS_PER_TURN, MAX_INTERVIEW_ROUNDS, MAX_REPEATS,
|
|
872
|
+
startInterview, recordAnswers, buildInterviewPrompt, parseInterviewTurn, humanSchedule,
|
|
873
|
+
MAX_SELF_CORRECTIONS, weakenedAgainstLastAttempt,
|
|
874
|
+
validateBlueprint, buildGraphFromBlueprint, branchLabel, describeBranches,
|
|
875
|
+
};
|