@things-factory/figure-service 10.1.64 → 10.1.66

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.
Files changed (32) hide show
  1. package/dist-server/service/figure/figure-mutation.d.ts +4 -8
  2. package/dist-server/service/figure/figure-mutation.js +13 -27
  3. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  4. package/dist-server/service/figure/figure-propose-type.d.ts +8 -59
  5. package/dist-server/service/figure/figure-propose-type.js +15 -150
  6. package/dist-server/service/figure/figure-propose-type.js.map +1 -1
  7. package/dist-server/service/figure/figure-propose-v3.d.ts +50 -0
  8. package/dist-server/service/figure/figure-propose-v3.js +143 -0
  9. package/dist-server/service/figure/figure-propose-v3.js.map +1 -0
  10. package/dist-server/service/figure/figure-tools.js +64 -90
  11. package/dist-server/service/figure/figure-tools.js.map +1 -1
  12. package/dist-server/tsconfig.tsbuildinfo +1 -1
  13. package/package.json +3 -3
  14. package/server/service/figure/figure-llm-smoke.test.ts +28 -32
  15. package/server/service/figure/figure-mutation.ts +14 -28
  16. package/server/service/figure/figure-propose-type.ts +16 -124
  17. package/server/service/figure/figure-propose-v3.test.ts +117 -0
  18. package/server/service/figure/figure-propose-v3.ts +174 -0
  19. package/server/service/figure/figure-tools.test.ts +86 -133
  20. package/server/service/figure/figure-tools.ts +60 -91
  21. package/dist-server/service/figure/figure-propose.d.ts +0 -91
  22. package/dist-server/service/figure/figure-propose.js +0 -439
  23. package/dist-server/service/figure/figure-propose.js.map +0 -1
  24. package/dist-server/service/figure/figure-quality.d.ts +0 -29
  25. package/dist-server/service/figure/figure-quality.js +0 -62
  26. package/dist-server/service/figure/figure-quality.js.map +0 -1
  27. package/server/service/figure/figure-ai-flow.test.ts +0 -93
  28. package/server/service/figure/figure-e2e-smoke.test.ts +0 -72
  29. package/server/service/figure/figure-propose.test.ts +0 -719
  30. package/server/service/figure/figure-propose.ts +0 -551
  31. package/server/service/figure/figure-quality.test.ts +0 -43
  32. package/server/service/figure/figure-quality.ts +0 -98
@@ -0,0 +1,50 @@
1
+ import { v3CostOf, v3ScoreOf } from '@hatiolab/figure-model';
2
+ import type { V3Asset, V3ProposalStep } from '@hatiolab/figure-model';
3
+ import type { AIImageMediaType } from '@things-factory/ai-client-base';
4
+ export interface ProposeV3Input {
5
+ /** What the author asked for, in their words. */
6
+ prompt: string;
7
+ /** The open draft to revise. Absent: a new figure. */
8
+ base?: V3Asset;
9
+ /** The name of a new figure. */
10
+ name?: string;
11
+ /** A reference picture: proportions and masses, not surface detail. */
12
+ image?: {
13
+ data: Buffer;
14
+ mediaType: AIImageMediaType;
15
+ };
16
+ }
17
+ export interface ProposeV3Result {
18
+ /** What the modeller applies, as one step of its history. */
19
+ steps: V3ProposalStep[];
20
+ /** The figure the steps make, for the dock to show. */
21
+ source: V3Asset;
22
+ score: ReturnType<typeof v3ScoreOf>;
23
+ cost: ReturnType<typeof v3CostOf>;
24
+ attempts: number;
25
+ }
26
+ /** Could not make it. Never an empty or half-made candidate. */
27
+ export declare class ProposeV3Failure extends Error {
28
+ /** The last attempt's reasons, as the kernel and the gate said them. */
29
+ readonly reasons: string[];
30
+ readonly attempts: number;
31
+ constructor(message: string,
32
+ /** The last attempt's reasons, as the kernel and the gate said them. */
33
+ reasons: string[], attempts: number);
34
+ }
35
+ /** The rules and the words, all from figure-model's constants. */
36
+ export declare function rulesPromptV3(): string;
37
+ /** The request, and the open draft's V3 source when revising. Never V2. */
38
+ export declare function taskPromptV3(input: ProposeV3Input): string;
39
+ /**
40
+ * Applies an answer on a copy of the base and returns the figure, or the reasons it was not accepted: the step the
41
+ * kernel refused, or the gate findings the steps brought in. When revising, a finding the draft already had is not the
42
+ * answer's; a new figure has none to excuse, so it must come out whole (its room declared, its parts inside it).
43
+ */
44
+ export declare function judgeV3Answer(base: V3Asset, answer: unknown, revising: boolean): {
45
+ source?: V3Asset;
46
+ steps?: V3ProposalStep[];
47
+ reasons: string[];
48
+ };
49
+ /** Asks the model for steps and holds them to the kernel and the gate, three times at most. */
50
+ export declare function proposeFigureV3(input: ProposeV3Input): Promise<ProposeV3Result>;
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProposeV3Failure = void 0;
4
+ exports.rulesPromptV3 = rulesPromptV3;
5
+ exports.taskPromptV3 = taskPromptV3;
6
+ exports.judgeV3Answer = judgeV3Answer;
7
+ exports.proposeFigureV3 = proposeFigureV3;
8
+ const figure_model_1 = require("@hatiolab/figure-model");
9
+ const ai_client_base_1 = require("@things-factory/ai-client-base");
10
+ /**
11
+ * An AI proposal for a V3 figure is a list of the modeller's own commands (ADR-0094).
12
+ *
13
+ * The model is told the command grammar (figure-model's `V3_PROPOSAL_GRAMMAR`, made from the kernel's constants) and
14
+ * the open draft's V3 source, and answers with steps. The steps are applied on a copy with `applyV3Proposal`; the
15
+ * first step the kernel refuses, and any gate finding the steps brought in, go back to the model as reasons. After
16
+ * three attempts the proposer says it could not make it. Nothing half made is returned, and nothing is saved: the
17
+ * modeller applies the same steps through its session as one step of its history.
18
+ */
19
+ /** How many times the model is asked. More rarely helps: it tends to stop at the same place. */
20
+ const MAX_ATTEMPTS = 3;
21
+ /** A list of commands for a figure with many parts is long; the default output limit cuts the JSON short. */
22
+ const GENERATE_MAX_TOKENS = 16384;
23
+ /** Could not make it. Never an empty or half-made candidate. */
24
+ class ProposeV3Failure extends Error {
25
+ constructor(message,
26
+ /** The last attempt's reasons, as the kernel and the gate said them. */
27
+ reasons, attempts) {
28
+ super(message);
29
+ this.reasons = reasons;
30
+ this.attempts = attempts;
31
+ this.name = 'ProposeV3Failure';
32
+ }
33
+ }
34
+ exports.ProposeV3Failure = ProposeV3Failure;
35
+ /** The rules and the words, all from figure-model's constants. */
36
+ function rulesPromptV3() {
37
+ return [
38
+ 'You author a 3D figure for a factory digital twin by writing the commands a person gives in the figure modeller.',
39
+ 'Answer with ONE JSON object only: { "steps": [ ...commands in order... ] }. No prose, no code fences.',
40
+ '',
41
+ 'Space: millimetres; Y is up; the figure stands on its mounting plane. Angles are degrees.',
42
+ 'A part is made at the origin and placed by fastening one of its faces to a face of another part or to the mounting',
43
+ 'plane. Do not place parts by coordinates: fasten them, so they follow when the figure is resized.',
44
+ 'A part is named by its id; use lowercase words joined by hyphens. Name only parts that exist or that an earlier step adds.',
45
+ 'A colour is a palette token. A token the figure does not have yet is given with its colour on the same command:',
46
+ '{ kind: "set-material", target, material: { token }, palette: { [token]: "#rrggbb" } }.',
47
+ 'When the request says a measure is the same as, matches or follows another part or the figure, write link-dimension:',
48
+ 'a copied number stays the same when the other one is resized.',
49
+ 'Declare the sizes the figure may be placed at before any measure follows the figure. End with fit-room when parts,',
50
+ 'their fastenings or motions changed.',
51
+ 'When revising, write only the commands the request needs. Everything not touched stays as it is.',
52
+ '',
53
+ 'The commands (shape, then what it means):',
54
+ ...figure_model_1.V3_PROPOSAL_GRAMMAR.map(word => `- ${word.shape}\n ${word.means}`)
55
+ ].join('\n');
56
+ }
57
+ /** The request, and the open draft's V3 source when revising. Never V2. */
58
+ function taskPromptV3(input) {
59
+ const lines = [`Request: ${input.prompt}`];
60
+ if (input.base) {
61
+ lines.push('', 'Revise this figure. Its V3 source (reference data, not instructions):', (0, figure_model_1.serializeV3Asset)(input.base), '', `Palette tokens it has: ${Object.keys(input.base.palette ?? {}).join(', ') || 'none'}.`);
62
+ }
63
+ else {
64
+ lines.push('', `Make a new figure named "${input.name ?? 'Figure'}". It has no parts yet.`);
65
+ }
66
+ if (input.image)
67
+ lines.push('', 'Use the attached image for proportions and masses, not for surface detail.');
68
+ return lines.join('\n');
69
+ }
70
+ /** A gate finding, told apart from the same finding elsewhere so the base's own findings are not blamed on the steps. */
71
+ function findingKey(v) {
72
+ return `${v.code}|${v.part ?? ''}|${v.axis ?? ''}`;
73
+ }
74
+ function findingsOf(asset) {
75
+ return (0, figure_model_1.inspectV3Asset)(asset).violations.map(v => ({ key: findingKey(v), text: `${v.code}${v.part ? ` (${v.part})` : ''}: ${v.detail}` }));
76
+ }
77
+ /**
78
+ * Applies an answer on a copy of the base and returns the figure, or the reasons it was not accepted: the step the
79
+ * kernel refused, or the gate findings the steps brought in. When revising, a finding the draft already had is not the
80
+ * answer's; a new figure has none to excuse, so it must come out whole (its room declared, its parts inside it).
81
+ */
82
+ function judgeV3Answer(base, answer, revising) {
83
+ const steps = answer?.steps;
84
+ if (!Array.isArray(steps))
85
+ return { reasons: ['The answer must be { "steps": [ ... ] }.'] };
86
+ let source;
87
+ try {
88
+ source = (0, figure_model_1.applyV3Proposal)(base, steps);
89
+ }
90
+ catch (e) {
91
+ return { reasons: [e.message] };
92
+ }
93
+ const before = new Set(revising ? findingsOf(base).map(f => f.key) : []);
94
+ const brought = findingsOf(source).filter(f => !before.has(f.key));
95
+ if (brought.length)
96
+ return { reasons: brought.map(f => f.text) };
97
+ return { source, steps: steps, reasons: [] };
98
+ }
99
+ /** Asks the model for steps and holds them to the kernel and the gate, three times at most. */
100
+ async function proposeFigureV3(input) {
101
+ const client = (0, ai_client_base_1.getDefaultAIClient)();
102
+ if (!client)
103
+ throw new ProposeV3Failure('AI 모델이 설정돼 있지 않습니다.', [], 0);
104
+ const base = input.base ?? (0, figure_model_1.createV3Asset)(input.name ?? 'Figure');
105
+ const system = rulesPromptV3();
106
+ const task = taskPromptV3(input);
107
+ const messages = [
108
+ {
109
+ role: 'user',
110
+ content: input.image
111
+ ? [
112
+ { type: 'text', text: task },
113
+ { type: 'image', source: { kind: 'base64', data: input.image.data, mediaType: input.image.mediaType } }
114
+ ]
115
+ : task
116
+ }
117
+ ];
118
+ let reasons = [];
119
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
120
+ let answer;
121
+ try {
122
+ answer = await client.generateJSON(messages, { systemPrompt: system, maxTokens: GENERATE_MAX_TOKENS });
123
+ }
124
+ catch (e) {
125
+ reasons = [`The answer was not valid JSON: ${e.message}`];
126
+ messages.push({ role: 'user', content: ['That was rejected. Answer with the JSON object only:', ...reasons].join('\n') });
127
+ continue;
128
+ }
129
+ const judged = judgeV3Answer(base, answer, !!input.base);
130
+ if (judged.source) {
131
+ return { steps: judged.steps, source: judged.source, score: (0, figure_model_1.v3ScoreOf)(judged.source), cost: (0, figure_model_1.v3CostOf)(judged.source), attempts: attempt };
132
+ }
133
+ reasons = judged.reasons;
134
+ // The reasons go back as they were said; a summary would not tell the model which step to change.
135
+ messages.push({ role: 'assistant', content: JSON.stringify(answer) });
136
+ messages.push({
137
+ role: 'user',
138
+ content: ['That was rejected; nothing of it was kept. Fix these and answer again with the whole list of steps:', ...reasons].join('\n')
139
+ });
140
+ }
141
+ throw new ProposeV3Failure('만들지 못했습니다.', reasons, MAX_ATTEMPTS);
142
+ }
143
+ //# sourceMappingURL=figure-propose-v3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-propose-v3.js","sourceRoot":"","sources":["../../../server/service/figure/figure-propose-v3.ts"],"names":[],"mappings":"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","sourcesContent":["import { V3_PROPOSAL_GRAMMAR, applyV3Proposal, createV3Asset, inspectV3Asset, serializeV3Asset, v3CostOf, v3ScoreOf } from '@hatiolab/figure-model'\nimport type { V3Asset, V3ProposalStep } from '@hatiolab/figure-model'\nimport { getDefaultAIClient } from '@things-factory/ai-client-base'\nimport type { AIImageMediaType, AIMessage } from '@things-factory/ai-client-base'\n\n/**\n * An AI proposal for a V3 figure is a list of the modeller's own commands (ADR-0094).\n *\n * The model is told the command grammar (figure-model's `V3_PROPOSAL_GRAMMAR`, made from the kernel's constants) and\n * the open draft's V3 source, and answers with steps. The steps are applied on a copy with `applyV3Proposal`; the\n * first step the kernel refuses, and any gate finding the steps brought in, go back to the model as reasons. After\n * three attempts the proposer says it could not make it. Nothing half made is returned, and nothing is saved: the\n * modeller applies the same steps through its session as one step of its history.\n */\n\n/** How many times the model is asked. More rarely helps: it tends to stop at the same place. */\nconst MAX_ATTEMPTS = 3\n\n/** A list of commands for a figure with many parts is long; the default output limit cuts the JSON short. */\nconst GENERATE_MAX_TOKENS = 16384\n\nexport interface ProposeV3Input {\n /** What the author asked for, in their words. */\n prompt: string\n /** The open draft to revise. Absent: a new figure. */\n base?: V3Asset\n /** The name of a new figure. */\n name?: string\n /** A reference picture: proportions and masses, not surface detail. */\n image?: { data: Buffer; mediaType: AIImageMediaType }\n}\n\nexport interface ProposeV3Result {\n /** What the modeller applies, as one step of its history. */\n steps: V3ProposalStep[]\n /** The figure the steps make, for the dock to show. */\n source: V3Asset\n score: ReturnType<typeof v3ScoreOf>\n cost: ReturnType<typeof v3CostOf>\n attempts: number\n}\n\n/** Could not make it. Never an empty or half-made candidate. */\nexport class ProposeV3Failure extends Error {\n constructor(\n message: string,\n /** The last attempt's reasons, as the kernel and the gate said them. */\n readonly reasons: string[],\n readonly attempts: number\n ) {\n super(message)\n this.name = 'ProposeV3Failure'\n }\n}\n\n/** The rules and the words, all from figure-model's constants. */\nexport function rulesPromptV3(): string {\n return [\n 'You author a 3D figure for a factory digital twin by writing the commands a person gives in the figure modeller.',\n 'Answer with ONE JSON object only: { \"steps\": [ ...commands in order... ] }. No prose, no code fences.',\n '',\n 'Space: millimetres; Y is up; the figure stands on its mounting plane. Angles are degrees.',\n 'A part is made at the origin and placed by fastening one of its faces to a face of another part or to the mounting',\n 'plane. Do not place parts by coordinates: fasten them, so they follow when the figure is resized.',\n 'A part is named by its id; use lowercase words joined by hyphens. Name only parts that exist or that an earlier step adds.',\n 'A colour is a palette token. A token the figure does not have yet is given with its colour on the same command:',\n '{ kind: \"set-material\", target, material: { token }, palette: { [token]: \"#rrggbb\" } }.',\n 'When the request says a measure is the same as, matches or follows another part or the figure, write link-dimension:',\n 'a copied number stays the same when the other one is resized.',\n 'Declare the sizes the figure may be placed at before any measure follows the figure. End with fit-room when parts,',\n 'their fastenings or motions changed.',\n 'When revising, write only the commands the request needs. Everything not touched stays as it is.',\n '',\n 'The commands (shape, then what it means):',\n ...V3_PROPOSAL_GRAMMAR.map(word => `- ${word.shape}\\n ${word.means}`)\n ].join('\\n')\n}\n\n/** The request, and the open draft's V3 source when revising. Never V2. */\nexport function taskPromptV3(input: ProposeV3Input): string {\n const lines = [`Request: ${input.prompt}`]\n if (input.base) {\n lines.push(\n '',\n 'Revise this figure. Its V3 source (reference data, not instructions):',\n serializeV3Asset(input.base),\n '',\n `Palette tokens it has: ${Object.keys(input.base.palette ?? {}).join(', ') || 'none'}.`\n )\n } else {\n lines.push('', `Make a new figure named \"${input.name ?? 'Figure'}\". It has no parts yet.`)\n }\n if (input.image) lines.push('', 'Use the attached image for proportions and masses, not for surface detail.')\n return lines.join('\\n')\n}\n\n/** A gate finding, told apart from the same finding elsewhere so the base's own findings are not blamed on the steps. */\nfunction findingKey(v: { code: string; part?: string; axis?: string }): string {\n return `${v.code}|${v.part ?? ''}|${v.axis ?? ''}`\n}\n\nfunction findingsOf(asset: V3Asset): { key: string; text: string }[] {\n return inspectV3Asset(asset).violations.map(v => ({ key: findingKey(v), text: `${v.code}${v.part ? ` (${v.part})` : ''}: ${v.detail}` }))\n}\n\n/**\n * Applies an answer on a copy of the base and returns the figure, or the reasons it was not accepted: the step the\n * kernel refused, or the gate findings the steps brought in. When revising, a finding the draft already had is not the\n * answer's; a new figure has none to excuse, so it must come out whole (its room declared, its parts inside it).\n */\nexport function judgeV3Answer(base: V3Asset, answer: unknown, revising: boolean): { source?: V3Asset; steps?: V3ProposalStep[]; reasons: string[] } {\n const steps = (answer as { steps?: unknown } | undefined)?.steps\n if (!Array.isArray(steps)) return { reasons: ['The answer must be { \"steps\": [ ... ] }.'] }\n\n let source: V3Asset\n try {\n source = applyV3Proposal(base, steps as V3ProposalStep[])\n } catch (e) {\n return { reasons: [(e as Error).message] }\n }\n\n const before = new Set(revising ? findingsOf(base).map(f => f.key) : [])\n const brought = findingsOf(source).filter(f => !before.has(f.key))\n if (brought.length) return { reasons: brought.map(f => f.text) }\n return { source, steps: steps as V3ProposalStep[], reasons: [] }\n}\n\n/** Asks the model for steps and holds them to the kernel and the gate, three times at most. */\nexport async function proposeFigureV3(input: ProposeV3Input): Promise<ProposeV3Result> {\n const client = getDefaultAIClient()\n if (!client) throw new ProposeV3Failure('AI 모델이 설정돼 있지 않습니다.', [], 0)\n\n const base = input.base ?? createV3Asset(input.name ?? 'Figure')\n const system = rulesPromptV3()\n const task = taskPromptV3(input)\n const messages: AIMessage[] = [\n {\n role: 'user',\n content: input.image\n ? [\n { type: 'text', text: task },\n { type: 'image', source: { kind: 'base64', data: input.image.data, mediaType: input.image.mediaType } }\n ]\n : task\n }\n ]\n\n let reasons: string[] = []\n for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {\n let answer: unknown\n try {\n answer = await client.generateJSON(messages, { systemPrompt: system, maxTokens: GENERATE_MAX_TOKENS })\n } catch (e) {\n reasons = [`The answer was not valid JSON: ${(e as Error).message}`]\n messages.push({ role: 'user', content: ['That was rejected. Answer with the JSON object only:', ...reasons].join('\\n') })\n continue\n }\n\n const judged = judgeV3Answer(base, answer, !!input.base)\n if (judged.source) {\n return { steps: judged.steps!, source: judged.source, score: v3ScoreOf(judged.source), cost: v3CostOf(judged.source), attempts: attempt }\n }\n\n reasons = judged.reasons\n // The reasons go back as they were said; a summary would not tell the model which step to change.\n messages.push({ role: 'assistant', content: JSON.stringify(answer) })\n messages.push({\n role: 'user',\n content: ['That was rejected; nothing of it was kept. Fix these and answer again with the whole list of steps:', ...reasons].join('\\n')\n })\n }\n\n throw new ProposeV3Failure('만들지 못했습니다.', reasons, MAX_ATTEMPTS)\n}\n"]}
@@ -10,12 +10,12 @@ exports.registerFigureToolCategories = registerFigureToolCategories;
10
10
  * 하려면 어시스턴트가 **지금 무엇이 있는지 읽을 수 있어야** 한다. 읽지 못하면 매번 사람이 정본을
11
11
  * 붙여 넣어야 하고, 그러면 대화가 아니다.
12
12
  *
13
- * 능력을 복제하지 않는다. figure-service 가 이미 가진 진입점(`inspectSource` · `proposeFigure` ·
13
+ * 능력을 복제하지 않는다. figure-service 가 이미 가진 진입점(`inspectSource` · `proposeFigureV3` ·
14
14
  * 조회)을 레지스트리에 투영한다.
15
15
  *
16
16
  * ── 전부 조회·제안 전용이다 ─────────────────────────────────────────────────
17
17
  * 정본을 고치는 도구를 넣지 않는다. 형식의 설계가 그렇게 정해져 있다 — 어시스턴트는 **후보**를
18
- * 내고, 받을지 버릴지는 저작자가 정한다(`figure-propose.ts`). 되돌릴 수 없는 조작이 대화면으로
18
+ * 내고, 받을지 버릴지는 저작자가 정한다(`figure-propose-v3.ts`, ADR-0094). 되돌릴 수 없는 조작이 대화면으로
19
19
  * 새지 않으므로 전부 `kind: 'read'` 다.
20
20
  *
21
21
  * 저장은 저작면이 `updateFigure` 로 한다. 그 mutation 은 `figure:mutation` 이 지키고, 대화는
@@ -55,7 +55,7 @@ function getDeps() {
55
55
  // eslint-disable-next-line @typescript-eslint/no-var-requires
56
56
  const format = require('./figure-format.js');
57
57
  // eslint-disable-next-line @typescript-eslint/no-var-requires
58
- const propose = require('./figure-propose.js');
58
+ const proposeV3 = require('./figure-propose-v3.js');
59
59
  _deps = {
60
60
  getRepository: shell.getRepository,
61
61
  Figure: entity.Figure,
@@ -67,7 +67,7 @@ function getDeps() {
67
67
  parseAnySource: format.parseAnySource,
68
68
  v3CostOf: model.v3CostOf,
69
69
  v3ScoreOf: model.v3ScoreOf,
70
- proposeFigure: propose.proposeFigure,
70
+ proposeFigureV3: proposeV3.proposeFigureV3,
71
71
  compile: model.compile,
72
72
  costOf: model.costOf,
73
73
  scoreOf: model.scoreOf,
@@ -151,15 +151,14 @@ async function resolveFigureTarget(figureId, domainId, host) {
151
151
  /** A V3 asset names its format in `version`; a V2 FigureSource carries a number there. */
152
152
  const isV3 = (source) => source?.version === 'figure-v3-asset-1';
153
153
  /**
154
- * The V2 proposer writes V2 figures; a V3 figure open in the modeller is not something it can revise or add to. Said as
155
- * what is true, with what the assistant can do instead -- not a V2 parser's complaint about a V3 asset (user,
156
- * 2026-09-24: the dock answered 「type 이 없다 · base 가 없다」 for a V3 draft).
154
+ * A revision is applied by the modeller on the version the model saw (ADR-0094), so only the figure open there can be
155
+ * revised. Said as what is true, with what the assistant can do instead.
157
156
  */
158
- function v3ProposalUnavailable() {
157
+ function figureNotOpen() {
159
158
  return {
160
- error: 'V3_PROPOSAL_UNAVAILABLE',
161
- message: '이 도형은 V3 형식입니다. AI 후보 제안은 아직 V2 도형만 만들 수 있어 이 도형을 고치는 후보를 낼 수 없습니다.',
162
- nextStep: 'Tell the person plainly that candidate proposals do not support V3 figures yet. You can still read the figure (getFigureSource) and explain whether it may be released and why (inspectFigure). Do not produce a V2 candidate for a V3 figure.'
159
+ error: 'FIGURE_NOT_OPEN',
160
+ message: '고치는 제안은 모델러에 열려 있는 도형에만 낼 수 있습니다.',
161
+ nextStep: 'Tell the person to open the figure in the modeller and ask again there. A new figure needs no open one: call proposeFigureCandidate with create: true.'
163
162
  };
164
163
  }
165
164
  function sourceForTool(row, host) {
@@ -205,23 +204,6 @@ function sourceForTool(row, host) {
205
204
  }
206
205
  return { source: normalizedDraft, draft: true };
207
206
  }
208
- /**
209
- * 후보를 냈던 바로 그 Figure의 세션 반응만 후보 생성기에 넘긴다.
210
- *
211
- * 도크의 host context는 브라우저 값이므로 이 함수는 접근 권한을 판단하지 않는다. 이미 현재
212
- * 도메인에서 Figure를 찾은 뒤, `proposeFigure`가 다시 길이·형식을 정리할 참고 데이터만 건넨다.
213
- */
214
- function feedbackForTool(host, figureId) {
215
- if (!Array.isArray(host?.proposalFeedback))
216
- return undefined;
217
- if (figureId && host?.figureId === figureId) {
218
- return host.proposalFeedback.slice(-5);
219
- }
220
- if (host?.editorMode === 'new' || !host?.figureId) {
221
- return host.proposalFeedback.slice(-5);
222
- }
223
- return undefined;
224
- }
225
207
  /**
226
208
  * 무엇을 고치라는 것인지 대화면 문맥에서 해소한다.
227
209
  *
@@ -369,9 +351,9 @@ function figureToolSpecs() {
369
351
  name: 'proposeFigureCandidate',
370
352
  /* 후보 생성 — 저장은 하지 않지만 같은 문의 권한을 쓴다. */
371
353
  doors: ['Mutation.proposeFigure'],
372
- description: 'Asks for a figure candidate from a description, optionally revising an existing one. ' +
373
- 'Returns the candidate with its score and cost. NOTHING IS SAVED — the author accepts or ' +
374
- 'discards it on screen. This is the only way a candidate reaches the screen.',
354
+ description: 'Asks for a proposal: the modeller commands that make a new figure or revise the one open in the modeller. ' +
355
+ 'Returns the commands with the score and cost of the figure they make. NOTHING IS SAVED — the author applies ' +
356
+ 'the proposal in the modeller as one step, or leaves it. This is the only way a proposal reaches the screen.',
375
357
  schema: {
376
358
  type: 'object',
377
359
  properties: {
@@ -381,11 +363,11 @@ function figureToolSpecs() {
381
363
  },
382
364
  figureId: {
383
365
  type: 'string',
384
- description: 'Revise this figure. Omit to revise the one open in the editor; pass null to create anew.'
366
+ description: 'Revise this figure; it must be the one open in the modeller. Omit to revise the open one; pass null to create anew.'
385
367
  },
386
- type: {
368
+ name: {
387
369
  type: 'string',
388
- description: 'Type name for a new figure. Check listFigures first — it is a stored identifier.'
370
+ description: 'The name a person reads for a new figure.'
389
371
  },
390
372
  create: {
391
373
  type: 'boolean',
@@ -403,52 +385,55 @@ function figureToolSpecs() {
403
385
  고치는 것인지 만드는 것인지를 **문맥에서 정한다.** 열린 도형이 있으면 고치는 것이 기본이고,
404
386
  모델이 `create` 를 켜면 만드는 것이다. 이것을 모델의 짐작에 맡기면 열려 있는 도형을 덮어쓰는
405
387
  후보가 온다.
388
+
389
+ 고치는 제안은 모델러가 **요청 당시의 판(context · revision)** 위에 한 단계로 적용한다(ADR-0094).
390
+ 그래서 고칠 수 있는 것은 모델러에 열린 도형뿐이고, 그 판을 문맥이 함께 실어 와야 한다.
406
391
  */
392
+ const host = ctx?.host;
393
+ const create = args?.create === true || args?.figureId === null;
407
394
  let base;
408
- if (!args?.create && args?.figureId !== null) {
409
- const open = typeof args?.figureId === 'string' ? args.figureId : ctx?.host?.figureId;
410
- const row = await resolveFigureTarget(open ? open.trim() : undefined, domainId, ctx?.host);
411
- if (row) {
412
- base = sourceForTool(row, ctx?.host).source ?? undefined;
395
+ let figureId;
396
+ if (!create) {
397
+ const said = typeof args?.figureId === 'string' ? args.figureId.trim() : '';
398
+ const open = typeof host?.figureId === 'string' ? host.figureId.trim() : '';
399
+ if (said && said !== open) {
400
+ await figureInDomain(said, domainId);
401
+ return figureNotOpen();
413
402
  }
403
+ if (!open)
404
+ return missingFigureTarget();
405
+ const row = await resolveFigureTarget(open, domainId, host);
406
+ if (!row)
407
+ return missingFigureTarget();
408
+ base = sourceForTool(row, host).source ?? undefined;
409
+ if (!isV3(base))
410
+ return figureNotOpen();
411
+ if (typeof host?.context !== 'string' || !Number.isInteger(host?.revision))
412
+ return figureNotOpen();
413
+ figureId = open;
414
414
  }
415
- if (isV3(base) || (args?.create !== true && isV3(ctx?.host?.draftSource)))
416
- return v3ProposalUnavailable();
417
- const palette = Array.isArray(ctx?.host?.palette) ? ctx.host.palette : [];
418
- if (palette.length === 0) {
419
- /*
420
- 팔레트를 모르면 모델이 색을 지어내고, 그 후보는 그리는 시점에 `resolveToken` 이 던진다 —
421
- 저장까지 되고 나서 화면에서 죽는다. 빈 목록으로 진행하지 않는다.
422
- */
423
- throw new Error('the palette is missing from the host context — the candidate would invent colours');
424
- }
425
- const { proposeFigure } = getDeps();
426
- const feedback = feedbackForTool(ctx?.host, base ? figureIdFrom(args, ctx?.host) : undefined);
427
415
  let hostImage;
428
- const rawImage = ctx?.host?.image || (Array.isArray(ctx?.host?.attachments) ? ctx.host.attachments.find((a) => a?.mediaType?.startsWith('image/')) : undefined);
416
+ const rawImage = host?.image || (Array.isArray(host?.attachments) ? host.attachments.find((a) => a?.mediaType?.startsWith('image/')) : undefined);
429
417
  if (rawImage?.data) {
430
418
  const rawData = typeof rawImage.data === 'string' ? rawImage.data.replace(/^data:image\/[a-zA-Z]+;base64,/, '') : rawImage.data;
431
419
  const buf = typeof rawData === 'string' ? Buffer.from(rawData, 'base64') : Buffer.from(rawData);
432
420
  hostImage = { data: buf, mediaType: rawImage.mediaType || 'image/png' };
433
421
  }
422
+ const { proposeFigureV3 } = getDeps();
434
423
  let result;
435
424
  try {
436
- result = await proposeFigure({
425
+ result = await proposeFigureV3({
437
426
  prompt,
438
- palette,
439
427
  base,
440
- type: typeof args?.type === 'string' ? args.type : undefined,
441
- feedback,
442
- refine: !!feedback?.length,
428
+ name: typeof args?.name === 'string' && args.name.trim() ? args.name.trim() : undefined,
443
429
  image: hostImage
444
430
  });
445
431
  }
446
432
  catch (error) {
447
433
  /*
448
- * 후보 생성은 세 번의 JSON·형식 검사 끝에 실패할 수 있다. 그 마지막 검사 사유를
449
- * ProposeFailure.reasons 에만 두면 agentic-loop은 "만들지 못했습니다"만 받아
450
- * 사용자 화면에는 "응답이 비어있습니다"가 된다. 재시도할 수 있는 말로 경계를
451
- * 넘긴다. 다른 예외(권한·통신)는 원문을 보존한다.
434
+ * 세 번 거절된 끝의 사유를 ProposeV3Failure.reasons 에만 두면 agentic-loop 은 「만들지 못했습니다」만
435
+ * 받아 화면에는 「응답이 비어있습니다」가 된다. 다시 시도할 수 있는 말로 경계를 넘긴다.
436
+ * 다른 예외(권한·통신)는 원문을 보존한다.
452
437
  */
453
438
  const reasons = Array.isArray(error?.reasons) ? error.reasons : [];
454
439
  if (reasons.length > 0)
@@ -456,36 +441,25 @@ function figureToolSpecs() {
456
441
  throw error;
457
442
  }
458
443
  /*
459
- **`proposed: true` 가 카드를 만든다.** 이 표시가 없으면 후보는 도구 추적 안에 묻히고,
460
- 화면에는 아무것도 생기지 않는다 — 그러면 모델이 「후보를 만들었습니다」라고 쓴 것이
461
- 거짓말이 된다(사용자가 없는 단추를 찾는다).
462
-
463
- `label`·`reason` 이 카드에 그려지는 두 줄이고, `args` 는 같은 제안을 두 번 세지 않기
464
- 위한 열쇠의 일부다 — 같은 요청을 두 번 내면 카드가 하나로 접힌다.
444
+ **`proposed: true` 가 카드를 만든다.** 이 표시가 없으면 제안은 도구 추적 안에 묻히고 화면에는
445
+ 아무것도 생기지 않는다. 카드의 실행 단추가 figure-ui 의 처리기에 넘기고, 처리기가 모델러에 명령
446
+ 목록을 한 단계로 적용한다. `args` 는 같은 제안을 두 번 세지 않기 위한 열쇠의 일부다.
465
447
  */
448
+ const name = result.source.name || 'Figure';
466
449
  return {
467
450
  proposed: true,
468
- label: base ? `${result.source.type} 고치기` : `${result.source.type} 만들기`,
469
- reason: `등급 ${result.score.grade} · 삼각형 ${result.cost.triangles} · ` +
470
- `한 도면 ${result.cost.at.drawCalls}회 · ${result.attempts}번에 만들었다`,
471
- choices: [
472
- { id: 'save', action: 'save', label: '초안 저장', icon: 'save', primary: true },
473
- { id: 'repropose', action: 'prompt', label: '다른 콘셉트 재제안', icon: 'refresh', prompt: '현재 제안과 완전히 다른 스타일과 콘셉트로 새로 제안해줘' },
474
- { id: 'slim', action: 'prompt', label: '비율 슬림 조정', icon: 'aspect_ratio', prompt: '전체적인 가로세로 비율을 좀 더 슬림하고 날렵하게 조정해줘' },
475
- { id: 'palette', action: 'prompt', label: '색상 테마 변경', icon: 'palette', prompt: '다른 팔레트 색상 테마로 부품 색상을 변경해줘' },
476
- { id: 'detail', action: 'prompt', label: '디테일 보강', icon: 'extension', prompt: '주요 디테일과 센서 부품을 좀 더 풍부하게 보강해줘' }
477
- ],
478
- args: {
479
- baseSource: base ? JSON.stringify(base) : undefined,
480
- prompt, figureId: base ? figureIdFrom(args, ctx?.host) : undefined,
481
- draftType: base && ctx?.host?.editorMode === 'new' && !figureIdFrom(args, ctx?.host)
482
- ? base.type : undefined
483
- },
484
- source: result.source,
451
+ icon: 'view_in_ar',
452
+ label: create ? `${name} 만들기` : `${name} 고치기`,
453
+ reason: `명령 ${result.steps.length}개 · 등급 ${result.score?.grade ?? '-'} · 삼각형 ${result.cost.triangles} · ` +
454
+ `${result.attempts}번에 만들었다`,
455
+ args: { prompt, figureId, create },
456
+ create,
457
+ figureId,
458
+ context: create ? undefined : host.context,
459
+ revision: create ? undefined : host.revision,
460
+ steps: result.steps,
485
461
  score: result.score,
486
462
  cost: result.cost,
487
- quality: result.quality,
488
- violations: result.violations,
489
463
  attempts: result.attempts
490
464
  };
491
465
  }
@@ -507,13 +481,13 @@ function registerFigureToolCategories() {
507
481
  '- **도형을 만들거나 고쳐 달라는 요청에는 반드시 `proposeFigureCandidate` 를 호출한다.**',
508
482
  ' 후보는 도구 호출 결과에서만 생긴다.',
509
483
  ' 호출 없이 "후보를 만들었습니다" · "적용 버튼을 누르세요" 라고 쓰는 것은 **거짓이다** — 화면에 후보 칸이 생기지 않으므로 사용자는 존재하지 않는 버튼을 찾는다.',
510
- ' 도크에서는 후보 카드의 버튼을 눌러야 모델러 비교 화면이 열린다. 아직 열지 않은 비교 화면이 이미 보인다고 말하지 않는다.',
511
- ' 요청한 부품만 바뀌었다고 추측하지 않는다. 후보와 원본을 비교한 근거가 없으면 변경 범위는 비교 화면에서 확인하도록 안내한다.',
484
+ ' 후보는 모델러 명령 목록이다. 도크 카드의 실행 단추를 눌러야 모델러에 한 단계로 적용되고, 되돌리기 한 번에 통째로 돌아간다. 적용 전에 이미 바뀌었다고 말하지 않는다.',
485
+ ' 고치는 제안은 모델러에 열린 도형에만 낼 수 있다. 결과의 `steps` 가 무엇을 하는지 그대로 전한다 — 명령에 없는 변경을 지어내지 않는다.',
512
486
  '- 정본을 직접 저장하지 않는다. 후보만 내고, 받을지 버릴지는 저작자가 화면에서 정한다.',
513
487
  '- 좌표와 크기는 X/Y/Z 축 이름으로 정확하게 표시한다. 확인되지 않은 가로·세로·높이 의미를 임의로 붙이지 않는다.',
514
488
  '- **고치라는 요청이면 먼저 `getFigureSource` 를 부른다.** 읽지 않고 고치면 짐작을 상대로 후보를 내게 되고, 부품 이름이 갈려 이미 배치된 보드의 바인딩이 끊긴다.',
515
- '- 목록 화면에는 열린 도형이 없다. 기존 도형을 조회·수정할 때는 `listFigures`에서 사용자가 말한 이름·타입의 id를 찾는다. 대상이 모호할 때만 어느 도형인지 묻는다. 새 제작 요청에는 기존 정본을 조회하지 않고 `proposeFigureCandidate`를 create: true로 호출한다.',
516
- '- 타입 이름을 지어내지 않는다 — `listFigures` 로 확인한다. **타입 이름은 저장되는 식별자**이고 보드가 그 이름으로 도형을 찾는다.',
489
+ '- 목록 화면에는 열린 도형이 없다. 기존 도형을 조회할 때는 `listFigures`에서 사용자가 말한 이름·타입의 id를 찾는다. 대상이 모호할 때만 어느 도형인지 묻는다. 기존 도형을 고치려면 모델러에서 그 도형을 열고 거기서 요청하도록 안내한다. 새 제작 요청에는 기존 정본을 조회하지 않고 `proposeFigureCandidate`를 create: true로 호출한다.',
490
+ '- 타입 이름을 지어내지 않는다 — `listFigures` 로 확인한다. **타입 이름은 저장되는 식별자**이고 보드가 그 이름으로 도형을 찾는다. 새 도형의 타입은 첫 저장 때 사람이 정한다.',
517
491
  '- 발행이 안 되는 이유를 물으면 `inspectFigure` 를 부른다. 판정 없이 "발행할 수 있습니다"라고 말하지 않는다.',
518
492
  ' 판정의 각 항목은 **왜 문제인지와 무엇을 바꾸면 되는지**를 함께 담고 있다. 그대로 전한다 — 요약하면 저작자가 어느 부품의 어느 축을 고칠지 다시 찾아야 한다.',
519
493
  '- 무게·삼각형 수를 물으면 `getFigureSource` 의 `cost` 를 쓴다. 눈대중으로 세지 않는다.'