@things-factory/figure-ui 10.1.63 → 10.1.65

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 (46) hide show
  1. package/client/figure-assistant.ts +26 -10
  2. package/client/graphql/index.ts +0 -58
  3. package/client/modeller/figure-v3-properties.ts +6 -1
  4. package/client/modeller/v3-ai-proposal.ts +111 -0
  5. package/client/modeller/v3-product-session.ts +10 -1
  6. package/client/pages/figure-v3-modeller-page.ts +39 -4
  7. package/client/types.ts +0 -39
  8. package/dist-client/figure-assistant.js +22 -10
  9. package/dist-client/figure-assistant.js.map +1 -1
  10. package/dist-client/graphql/index.d.ts +1 -19
  11. package/dist-client/graphql/index.js +0 -46
  12. package/dist-client/graphql/index.js.map +1 -1
  13. package/dist-client/modeller/figure-v3-properties.js +6 -1
  14. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  15. package/dist-client/modeller/v3-ai-proposal.d.ts +51 -0
  16. package/dist-client/modeller/v3-ai-proposal.js +64 -0
  17. package/dist-client/modeller/v3-ai-proposal.js.map +1 -0
  18. package/dist-client/modeller/v3-product-session.d.ts +35 -1
  19. package/dist-client/modeller/v3-product-session.js +8 -0
  20. package/dist-client/modeller/v3-product-session.js.map +1 -1
  21. package/dist-client/pages/figure-v3-modeller-page.d.ts +7 -1
  22. package/dist-client/pages/figure-v3-modeller-page.js +46 -5
  23. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  24. package/dist-client/route.d.ts +1 -1
  25. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/dist-client/types.d.ts +0 -44
  28. package/dist-client/types.js.map +1 -1
  29. package/package.json +5 -5
  30. package/test/figure-assistant-surface.test.ts +9 -6
  31. package/test/v3-ai-proposal.test.ts +112 -0
  32. package/test/v3-refusals.test.ts +5 -0
  33. package/translations/en.json +6 -1
  34. package/translations/ja.json +6 -1
  35. package/translations/ko.json +6 -1
  36. package/translations/ms.json +6 -1
  37. package/translations/zh.json +6 -1
  38. package/client/modeller/proposal-session.ts +0 -98
  39. package/client/modeller/proposal.ts +0 -171
  40. package/dist-client/modeller/proposal-session.d.ts +0 -28
  41. package/dist-client/modeller/proposal-session.js +0 -66
  42. package/dist-client/modeller/proposal-session.js.map +0 -1
  43. package/dist-client/modeller/proposal.d.ts +0 -45
  44. package/dist-client/modeller/proposal.js +0 -114
  45. package/dist-client/modeller/proposal.js.map +0 -1
  46. package/test/ai-proposal-contract.test.ts +0 -563
@@ -101,50 +101,6 @@ export interface FigureFinding {
101
101
  message: string;
102
102
  at?: string;
103
103
  }
104
- /**
105
- * 저작 보조가 낸 후보.
106
- *
107
- * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다 — 서버는 저장하지 않고
108
- * 돌려주기만 한다.
109
- */
110
- export interface FigureProposal {
111
- /** 후보 정본. JSON 문자열이다 — 화면이 파싱한다. */
112
- source: string;
113
- score: number;
114
- grade: string;
115
- triangles: number;
116
- /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */
117
- groups: number;
118
- /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */
119
- quality: {
120
- status: 'ready' | 'review';
121
- summary: string;
122
- findings: Array<{
123
- dimension: string;
124
- code: string;
125
- message: string;
126
- }>;
127
- visualRegions: number;
128
- visualCoverage: number;
129
- visualReadability: number;
130
- };
131
- /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */
132
- violations: FigureFinding[];
133
- /** 몇 번 만에 됐나. 진단용이다. */
134
- attempts: number;
135
- }
136
- /** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */
137
- export interface ProposalFeedback {
138
- outcome: 'accepted' | 'discarded';
139
- selectedChanges: number;
140
- totalChanges: number;
141
- note?: string;
142
- grade?: string;
143
- quality?: {
144
- status?: 'ready' | 'review';
145
- findingCodes?: string[];
146
- };
147
- }
148
104
  /**
149
105
  * 발행 판정 한 줄.
150
106
  *
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * 화면이 다루는 모양.\n *\n * 정본 형식(`FigureSource`)의 타입은 **여기서 다시 정의하지 않는다** —\n * `@hatiolab/figure-model` 에서 가져온다. 두 벌이면 갈리고, 갈리면 화면이 서버가\n * 거절할 것을 만들어 낸다.\n */\nexport type { FigureSource, FigureBlueprint, FigurePart, ErrorCode, ViolationCode } from '@hatiolab/figure-model'\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 저작면에서만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** Which format the stored source is in — `'v2'`, `'v3'`, or null when the server cannot tell. */\n format?: 'v2' | 'v3' | null\n /** `FigureSource` JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n}\n\n/**\n * 발행된 판 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 판본은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 저작 보조가 낸 후보.\n *\n * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다 — 서버는 저장하지 않고\n * 돌려주기만 한다.\n */\nexport interface FigureProposal {\n /** 후보 정본. JSON 문자열이다 — 화면이 파싱한다. */\n source: string\n score: number\n grade: string\n triangles: number\n /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */\n groups: number\n /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */\n quality: {\n status: 'ready' | 'review'\n summary: string\n findings: Array<{ dimension: string; code: string; message: string }>\n visualRegions: number\n visualCoverage: number\n visualReadability: number\n }\n /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */\n violations: FigureFinding[]\n /** 몇 번 만에 됐나. 진단용이다. */\n attempts: number\n}\n\n/** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */\nexport interface ProposalFeedback {\n outcome: 'accepted' | 'discarded'\n selectedChanges: number\n totalChanges: number\n note?: string\n grade?: string\n quality?: { status?: 'ready' | 'review'; findingCodes?: string[] }\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\n/** One reversible anchor edit the sizing gate has measured against this exact source. */\nexport interface FigureGateFix {\n findingWay: string\n part: string\n axis: 'x' | 'y' | 'z'\n from?: string\n to: 'min' | 'center' | 'max' | 'span'\n resolved: number\n /** All blocking sizing cases clear after this exact edit was remeasured server-side. */\n safe: boolean\n}\n\n/** A server-remeasured set of draft-only changes that clears every blocking sizing split. */\nexport interface FigureGateFixPlan {\n fixes: FigureGateFix[]\n resolved: number\n safe: boolean\n}\n\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */\n fixes?: FigureGateFix[]\n fixPlan?: FigureGateFixPlan\n blocking: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * 화면이 다루는 모양.\n *\n * 정본 형식(`FigureSource`)의 타입은 **여기서 다시 정의하지 않는다** —\n * `@hatiolab/figure-model` 에서 가져온다. 두 벌이면 갈리고, 갈리면 화면이 서버가\n * 거절할 것을 만들어 낸다.\n */\nexport type { FigureSource, FigureBlueprint, FigurePart, ErrorCode, ViolationCode } from '@hatiolab/figure-model'\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 저작면에서만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** Which format the stored source is in — `'v2'`, `'v3'`, or null when the server cannot tell. */\n format?: 'v2' | 'v3' | null\n /** `FigureSource` JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n}\n\n/**\n * 발행된 판 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 판본은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\n/** One reversible anchor edit the sizing gate has measured against this exact source. */\nexport interface FigureGateFix {\n findingWay: string\n part: string\n axis: 'x' | 'y' | 'z'\n from?: string\n to: 'min' | 'center' | 'max' | 'span'\n resolved: number\n /** All blocking sizing cases clear after this exact edit was remeasured server-side. */\n safe: boolean\n}\n\n/** A server-remeasured set of draft-only changes that clears every blocking sizing split. */\nexport interface FigureGateFixPlan {\n fixes: FigureGateFix[]\n resolved: number\n safe: boolean\n}\n\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */\n fixes?: FigureGateFix[]\n fixPlan?: FigureGateFixPlan\n blocking: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.63",
3
+ "version": "10.1.65",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,8 +27,8 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.1.74",
31
- "@hatiolab/things-scene": "^10.1.50",
30
+ "@hatiolab/figure-model": "^0.1.76",
31
+ "@hatiolab/things-scene": "^10.1.51",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
34
  "@operato/graphql": "^10.0.0",
@@ -40,8 +40,8 @@
40
40
  "@operato/styles": "^10.0.0",
41
41
  "@operato/utils": "^10.0.0",
42
42
  "@things-factory/ai-assistant": "^10.1.58",
43
- "@things-factory/figure-service": "^10.1.63",
43
+ "@things-factory/figure-service": "^10.1.65",
44
44
  "three": "^0.185.1"
45
45
  },
46
- "gitHead": "9d23a04edc8129d7ab1b066e1fb7a2855b6a0df8"
46
+ "gitHead": "87b95ef312f11d3d646f6e2247ee202b68d23e3f"
47
47
  }
@@ -20,7 +20,7 @@ test('a saved figure provides a surface anchored on its row', () => {
20
20
  })
21
21
 
22
22
  test('the draft is read when the message is sent, as host context the figure tools read', () => {
23
- assert.match(source, /hostContext: \(\) => \(\{[\s\S]*?figureId: id,[\s\S]*?draftSource: JSON\.parse\(this\.session\.source\(\)\),[\s\S]*?palette: Object\.keys\(activePalette\(\)\)/)
23
+ assert.match(source, /hostContext: \(\) => \(\{[\s\S]*?figureId: id,[\s\S]*?draftSource: JSON\.parse\(this\.session\.source\(\)\),[\s\S]*?context: this\.session\.read\(\)\.context,\s*revision: this\.session\.read\(\)\.revision,/)
24
24
  })
25
25
 
26
26
  test('an unsaved figure provides nothing', () => {
@@ -33,19 +33,22 @@ test('the surface is taken back when the page goes inactive or leaves', () => {
33
33
  assert.doesNotMatch(source, /figure-ai-context/, 'the old window-event channel is gone')
34
34
  })
35
35
 
36
- test('the V3 apply entry stays for the proposer that writes V3 changes', () => {
36
+ test('the dock applies a proposal through the page, on the version the model saw', () => {
37
37
  assert.match(source, /window\.addEventListener\('figure-v3-apply', this\.applyAI\)/)
38
+ assert.match(source, /this\.session\.applyProposal\(detail\.steps, detail\.revision, detail\.context\)/)
39
+ assert.match(source, /this\.takeNewFigureProposal\(\)/, 'a new figure opened for a proposal takes its steps')
38
40
  })
39
41
 
40
- test('a figure candidate is answered - unavailable, in words - never left without a handler', () => {
42
+ test('a figure proposal is answered by its handler, in words, never left without one', () => {
41
43
  const handler = readFileSync(fileURLToPath(new URL('../client/figure-assistant.ts', import.meta.url)), 'utf8')
42
44
  assert.match(handler, /registerProposalHandler\(FIGURE_CANDIDATE_TOOL,/)
43
45
  assert.match(handler, /export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'/)
44
- assert.match(handler, /verdict: 'unavailable'/)
45
- assert.match(handler, /figure\.text\.candidate-cannot-open/)
46
+ assert.match(handler, /runFigureProposal\(/)
47
+ assert.match(handler, /context\.notify\(/)
46
48
  for (const lang of ['en', 'ko', 'ja', 'zh', 'ms']) {
47
49
  const words = JSON.parse(readFileSync(fileURLToPath(new URL(`../translations/${lang}.json`, import.meta.url)), 'utf8'))
48
- assert.ok(words['figure.text.candidate-cannot-open'], `${lang} has no words for it`)
50
+ for (const key of ['applied', 'stale', 'refused', 'needs-modeller', 'not-opened', 'new-figure-unsaved'])
51
+ assert.ok(words[`figure.text.proposal-${key}`], `${lang} has no words for proposal-${key}`)
49
52
  }
50
53
  const index = readFileSync(fileURLToPath(new URL('../client/index.ts', import.meta.url)), 'utf8')
51
54
  assert.match(index, /figure-assistant\.js/)
@@ -0,0 +1,112 @@
1
+ /*
2
+ * An AI proposal reaching the modeller (ADR-0094): the modeller's commands, applied as one step of its history on the
3
+ * version the model saw, undone whole; every outcome said in words.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { createV3Asset, addV3Box, serializeV3Asset } from '@hatiolab/figure-model'
10
+
11
+ import { createV3ProductSession } from '../client/modeller/v3-product-session.js'
12
+ import { pendNewFigure, runFigureProposal, takePendingNewFigure, type FigureApplyDetail } from '../client/modeller/v3-ai-proposal.js'
13
+
14
+ const ko = JSON.parse(readFileSync(fileURLToPath(new URL('../translations/ko.json', import.meta.url)), 'utf8'))
15
+ const say = (key: string, params: Record<string, string> = {}) => {
16
+ assert.ok(ko[key], `ko has no words for ${key}`)
17
+ return ko[key].replace(/\{(\w+)\}/g, (_: string, k: string) => params[k] ?? '')
18
+ }
19
+
20
+ const source = () =>
21
+ serializeV3Asset(addV3Box(createV3Asset('A'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
22
+ const store = {
23
+ load: async (id: string) => ({ id, source: source() }),
24
+ save: async (q: { id?: string; source: string }) => ({ id: q.id ?? 'created', source: q.source })
25
+ }
26
+ const steps = [
27
+ { kind: 'add-part', part: { id: 'lid', kind: 'box', dimensions: { width: 100, height: 10, depth: 100 } } },
28
+ { kind: 'attach', part: 'lid', face: 'bottom', to: { part: 'body', face: 'top' } },
29
+ { kind: 'rename', name: 'Box with lid' }
30
+ ] as any[]
31
+
32
+ test('a proposal is one step of the history on the version the model saw, undone whole', async () => {
33
+ const s = createV3ProductSession(store)
34
+ await s.open('A')
35
+ const seen = s.read()
36
+ s.applyProposal(steps, seen.revision!, seen.context!)
37
+ assert.equal(s.read().name, 'Box with lid')
38
+ assert.ok(s.read().geometry.length > seen.geometry.length)
39
+ s.undo()
40
+ assert.equal(s.read().name, 'A', 'one undo takes the whole proposal back')
41
+ assert.equal(s.read().geometry.length, seen.geometry.length)
42
+ assert.equal(s.read().canUndo, false)
43
+ })
44
+
45
+ test('a person\'s edit after the proposal was asked makes it stale -- refused, nothing applied', async () => {
46
+ const s = createV3ProductSession(store)
47
+ await s.open('A')
48
+ const seen = s.read()
49
+ s.apply([{ kind: 'set-design', input: 'body.size.y', unit: 'mm', value: 150 }])
50
+ assert.throws(() => s.applyProposal(steps, seen.revision!, seen.context!), (e: any) => e.code === 'REVISION_CONFLICT')
51
+ assert.equal(s.read().name, 'A')
52
+ })
53
+
54
+ const taker = (result: Partial<FigureApplyDetail>) => ({
55
+ offered: [] as FigureApplyDetail[],
56
+ opened: [] as unknown[],
57
+ offer(detail: FigureApplyDetail) {
58
+ this.offered.push(detail)
59
+ return Object.assign(detail, result)
60
+ },
61
+ async openNew(s: unknown[]) {
62
+ this.opened.push(s)
63
+ return { applied: true }
64
+ }
65
+ })
66
+
67
+ test('applied: said with how many commands, and the card carries the version to the page', async () => {
68
+ const target = taker({ applied: true })
69
+ const out = await runFigureProposal({ steps, context: 'c', revision: 3 }, target, say)
70
+ assert.deepEqual(out, { verdict: 'accepted', applied: 3, note: '명령 3개를 한 단계로 적용했습니다.' })
71
+ assert.equal(target.offered[0]!.context, 'c')
72
+ assert.equal(target.offered[0]!.revision, 3)
73
+ })
74
+
75
+ test('refused: a stale version is said as what happened; any other refusal carries its reason', async () => {
76
+ const stale = await runFigureProposal({ steps }, taker({ applied: false, code: 'REVISION_CONFLICT', error: 'editor changed' }), say)
77
+ assert.equal(stale.verdict, 'refused')
78
+ assert.match(stale.note!, /모델이 바뀌어/)
79
+ const other = await runFigureProposal({ steps }, taker({ applied: false, code: 'ATTACH_FACE', error: 'faces along x' }), say)
80
+ assert.match(other.note!, /적용하지 않았습니다: faces along x/)
81
+ })
82
+
83
+ test('a revision with no modeller to take it is not applied anywhere else', async () => {
84
+ const target = taker({})
85
+ const out = await runFigureProposal({ steps }, target, say)
86
+ assert.equal(out.verdict, 'unavailable')
87
+ assert.equal(target.opened.length, 0, 'a revision never opens a new figure')
88
+ })
89
+
90
+ test('a new figure with no new modeller on screen opens one', async () => {
91
+ const target = taker({})
92
+ const out = await runFigureProposal({ steps, create: true }, target, say)
93
+ assert.equal(out.verdict, 'accepted')
94
+ assert.equal(target.opened.length, 1)
95
+ })
96
+
97
+ test('an empty card is not applied', async () => {
98
+ assert.equal((await runFigureProposal({ steps: [] }, taker({ applied: true }), say)).verdict, 'unavailable')
99
+ })
100
+
101
+ test('a new figure\'s steps wait for the page once, and lapse if no page takes them', async () => {
102
+ const waiting = pendNewFigure(steps, 50)
103
+ const taken = takePendingNewFigure()!
104
+ assert.equal(taken.steps, steps)
105
+ assert.equal(takePendingNewFigure(), undefined, 'taken once')
106
+ taken.settle({ applied: true })
107
+ assert.deepEqual(await waiting, { applied: true })
108
+
109
+ const lapsing = pendNewFigure(steps, 10)
110
+ assert.deepEqual(await lapsing, { applied: false, code: 'not-opened' })
111
+ assert.equal(takePendingNewFigure(), undefined, 'a later new figure does not receive them')
112
+ })
@@ -116,4 +116,9 @@ test('배치를 바꿀 수 없는 까닭 — 평면에 붙은 부품과 새 평
116
116
  const said = placementRefusalOf(refused, say)!
117
117
  assert.ok(said.includes('post (bottom)') && said.includes('벽 기반') && said.includes('back'), said)
118
118
  assert.equal(placementRefusalOf(new Error('x'), say), null)
119
+ /* The placement list sends its command through the page, not the editor: the page says it in words too, and the list goes back. */
120
+ const page = readFileSync(fileURLToPath(new URL('../client/pages/figure-v3-modeller-page.ts', import.meta.url)), 'utf8')
121
+ assert.ok(page.includes('placementRefusalOf(error, (key, params) => i18next.t(key, params))'), '페이지 경로도 사람의 말로')
122
+ const props = readFileSync(fileURLToPath(new URL('../client/modeller/figure-v3-properties.ts', import.meta.url)), 'utf8')
123
+ assert.ok(props.includes('select.value = current'), '거절되면 목록이 원래 배치로 돌아온다')
119
124
  })
@@ -711,7 +711,12 @@
711
711
  "figure.label.twist": "twist (°)",
712
712
  "figure.text.instance-size-undeclared": "the figure has no size, so a board cannot resize it.",
713
713
  "figure.button.declare-instance-size": "declare the figure size",
714
- "figure.text.candidate-cannot-open": "this candidate is a V2 figure and the modeller opens V3 figures only, so it cannot be opened for review yet.",
714
+ "figure.text.proposal-applied": "applied {n} commands as one step.",
715
+ "figure.text.proposal-needs-modeller": "this proposal can be applied only while its figure is open in the modeller.",
716
+ "figure.text.proposal-new-figure-unsaved": "a new figure is being made, so the proposal was not applied. save or discard it, then run it again.",
717
+ "figure.text.proposal-not-opened": "a new figure could not be opened in the modeller, so the proposal was not applied.",
718
+ "figure.text.proposal-refused": "the modeller did not apply the proposal: {reason}",
719
+ "figure.text.proposal-stale": "the model changed after the proposal was made, so it was not applied. ask again.",
715
720
  "figure.label.follows-plus": "plus (mm)",
716
721
  "figure.label.motion-travel": "travel",
717
722
  "figure.label.motion-travel-mm": "its own range (mm)",
@@ -711,7 +711,12 @@
711
711
  "figure.label.twist": "ひねり (°)",
712
712
  "figure.text.instance-size-undeclared": "図形サイズがないため、ボードでサイズを変えられません。",
713
713
  "figure.button.declare-instance-size": "図形サイズを宣言",
714
- "figure.text.candidate-cannot-open": "この候補は V2 図形で、モデラーは V3 図形しか開けないため、まだレビュー用に開けません。",
714
+ "figure.text.proposal-applied": "{n} 個のコマンドを 1 ステップとして適用しました。",
715
+ "figure.text.proposal-needs-modeller": "この提案は、その図形がモデラーで開いているときだけ適用できます。",
716
+ "figure.text.proposal-new-figure-unsaved": "作成中の新しい図形があるため、提案を適用しませんでした。保存または破棄してから、もう一度実行してください。",
717
+ "figure.text.proposal-not-opened": "新しい図形をモデラーで開けなかったため、提案を適用しませんでした。",
718
+ "figure.text.proposal-refused": "モデラーは提案を適用しませんでした: {reason}",
719
+ "figure.text.proposal-stale": "提案の後にモデルが変わったため、適用しませんでした。もう一度依頼してください。",
715
720
  "figure.label.follows-plus": "加算 (mm)",
716
721
  "figure.label.motion-travel": "移動距離",
717
722
  "figure.label.motion-travel-mm": "自分の範囲 (mm)",
@@ -711,7 +711,12 @@
711
711
  "figure.label.twist": "비틀기 (°)",
712
712
  "figure.text.instance-size-undeclared": "도형 크기가 없어 보드에서 크기를 바꿀 수 없습니다.",
713
713
  "figure.button.declare-instance-size": "도형 크기 선언",
714
- "figure.text.candidate-cannot-open": "이 후보는 V2 도형이고 모델러는 V3 도형만 열 수 있어, 아직 검토하도록 열 수 없습니다.",
714
+ "figure.text.proposal-applied": "명령 {n}개를 한 단계로 적용했습니다.",
715
+ "figure.text.proposal-needs-modeller": "이 제안은 모델러에 그 도형이 열려 있을 때만 적용할 수 있습니다.",
716
+ "figure.text.proposal-new-figure-unsaved": "만들고 있는 새 Figure가 있어 제안을 적용하지 않았습니다. 저장하거나 버린 뒤 다시 실행해 주세요.",
717
+ "figure.text.proposal-not-opened": "새 Figure를 모델러에 열지 못해 제안을 적용하지 않았습니다.",
718
+ "figure.text.proposal-refused": "모델러가 제안을 적용하지 않았습니다: {reason}",
719
+ "figure.text.proposal-stale": "제안을 받은 뒤 모델이 바뀌어 적용하지 않았습니다. 다시 요청해 주세요.",
715
720
  "figure.label.follows-plus": "더하기 (mm)",
716
721
  "figure.label.motion-travel": "이동 거리",
717
722
  "figure.label.motion-travel-mm": "제 범위 (mm)",
@@ -711,7 +711,12 @@
711
711
  "figure.label.twist": "pusingan (°)",
712
712
  "figure.text.instance-size-undeclared": "rajah tiada saiz, jadi papan tidak boleh mengubah saiznya.",
713
713
  "figure.button.declare-instance-size": "isytihar saiz rajah",
714
- "figure.text.candidate-cannot-open": "calon ini ialah rajah V2 dan pemodel hanya membuka rajah V3, jadi ia belum boleh dibuka untuk semakan.",
714
+ "figure.text.proposal-applied": "{n} arahan digunakan sebagai satu langkah.",
715
+ "figure.text.proposal-needs-modeller": "cadangan ini hanya boleh digunakan semasa rajahnya dibuka dalam pemodel.",
716
+ "figure.text.proposal-new-figure-unsaved": "rajah baharu sedang dibuat, jadi cadangan tidak digunakan. simpan atau buang dahulu, kemudian jalankan semula.",
717
+ "figure.text.proposal-not-opened": "rajah baharu tidak dapat dibuka dalam pemodel, jadi cadangan tidak digunakan.",
718
+ "figure.text.proposal-refused": "pemodel tidak menggunakan cadangan: {reason}",
719
+ "figure.text.proposal-stale": "model berubah selepas cadangan dibuat, jadi ia tidak digunakan. minta sekali lagi.",
715
720
  "figure.label.follows-plus": "tambah (mm)",
716
721
  "figure.label.motion-travel": "jarak gerak",
717
722
  "figure.label.motion-travel-mm": "julat sendiri (mm)",
@@ -711,7 +711,12 @@
711
711
  "figure.label.twist": "扭转 (°)",
712
712
  "figure.text.instance-size-undeclared": "图形没有尺寸,看板上无法调整大小。",
713
713
  "figure.button.declare-instance-size": "声明图形尺寸",
714
- "figure.text.candidate-cannot-open": "此候选为 V2 图形,而建模器只能打开 V3 图形,因此暂时无法打开以供审阅。",
714
+ "figure.text.proposal-applied": "已将 {n} 条命令作为一步应用。",
715
+ "figure.text.proposal-needs-modeller": "只有在建模器中打开该图形时才能应用此提案。",
716
+ "figure.text.proposal-new-figure-unsaved": "正在制作一个新图形,因此未应用该提案。请保存或放弃后再运行。",
717
+ "figure.text.proposal-not-opened": "无法在建模器中打开新图形,因此未应用该提案。",
718
+ "figure.text.proposal-refused": "建模器未应用该提案:{reason}",
719
+ "figure.text.proposal-stale": "提案生成后模型已更改,因此未应用。请重新请求。",
715
720
  "figure.label.follows-plus": "加 (mm)",
716
721
  "figure.label.motion-travel": "行程",
717
722
  "figure.label.motion-travel-mm": "自身范围 (mm)",
@@ -1,98 +0,0 @@
1
- import type { FigureSource } from '@hatiolab/figure-model'
2
-
3
- import type { FigureGateFix, FigureProposal, ProposalFeedback } from '../types.js'
4
- import { diffProposal } from './proposal.js'
5
-
6
- /**
7
- * 한 번의 AI 후보 검토에만 존재하는 상태다.
8
- *
9
- * 후보는 정본이 아니다. 후보 원본·선택·메모·측정값을 한 값으로 묶어 두면 화면이
10
- * 수락/거절 중 일부만 이전 후보에서 남기는 일이 없다.
11
- */
12
- export interface ProposalSession {
13
- source: FigureSource
14
- title: string
15
- picked: Set<string>
16
- note: string
17
- metrics?: Pick<FigureProposal, 'grade' | 'triangles' | 'groups' | 'attempts' | 'quality'>
18
- /**
19
- * The release-check fix this session was opened from, if any. The page translates the title from it;
20
- * this module stays free of i18n because node tests import it directly.
21
- */
22
- fix?: FigureGateFix
23
- }
24
-
25
- function selectedKeys(current: FigureSource | undefined, source: FigureSource): Set<string> {
26
- return new Set(diffProposal(current, source).map(change => change.key))
27
- }
28
-
29
- /** 서버 후보를 검토 세션으로 연다. 저장 형식이 아니면 화면에 후보를 열지 않는다. */
30
- export function openAiProposal(current: FigureSource | undefined, proposal: FigureProposal): ProposalSession {
31
- const source = JSON.parse(proposal.source) as FigureSource
32
- if (!source || !Array.isArray(source.parts)) {
33
- throw new Error('the AI candidate is not a figure source: parts is missing or not an array')
34
- }
35
-
36
- return {
37
- source,
38
- title: '',
39
- picked: selectedKeys(current, source),
40
- note: '',
41
- metrics: {
42
- grade: proposal.grade,
43
- triangles: proposal.triangles,
44
- groups: proposal.groups,
45
- attempts: proposal.attempts,
46
- quality: proposal.quality
47
- }
48
- }
49
- }
50
-
51
- /** 발행 게이트가 측정한 한-앵커 수정도 같은 후보 세션으로 연다. */
52
- export function openSizingFix(current: FigureSource, fix: FigureGateFix): ProposalSession | undefined {
53
- if (!current.parts.some(part => part.name === fix.part)) return undefined
54
-
55
- const source: FigureSource = {
56
- ...current,
57
- parts: current.parts.map(part =>
58
- part.name === fix.part ? { ...part, anchor: { ...(part.anchor ?? {}), [fix.axis]: fix.to } } : part
59
- )
60
- }
61
-
62
- return {
63
- source,
64
- title: '',
65
- fix,
66
- picked: selectedKeys(current, source),
67
- note: ''
68
- }
69
- }
70
-
71
- /** 후보 검토 중 사람의 선택을 뒤집는다. 기존 Set을 바꾸지 않아 Lit 상태 경계도 보존한다. */
72
- export function toggleProposalChange(session: ProposalSession, key: string): ProposalSession {
73
- const picked = new Set(session.picked)
74
- if (picked.has(key)) picked.delete(key)
75
- else picked.add(key)
76
- return { ...session, picked }
77
- }
78
-
79
- /** 사람의 결정을, 다음 AI 요청에만 쓰는 제한된 피드백으로 만든다. */
80
- export function feedbackFromSession(
81
- current: FigureSource | undefined,
82
- session: ProposalSession,
83
- outcome: ProposalFeedback['outcome']
84
- ): ProposalFeedback | undefined {
85
- if (!session.metrics) return undefined
86
-
87
- return {
88
- outcome,
89
- selectedChanges: outcome === 'accepted' ? session.picked.size : 0,
90
- totalChanges: diffProposal(current, session.source).length,
91
- note: session.note.trim().slice(0, 500) || undefined,
92
- grade: session.metrics.grade,
93
- quality: {
94
- status: session.metrics.quality.status,
95
- findingCodes: session.metrics.quality.findings.map(finding => finding.code)
96
- }
97
- }
98
- }
@@ -1,171 +0,0 @@
1
- import type { FigurePart, FigureSource } from '@hatiolab/figure-model'
2
-
3
- /**
4
- * AI 후보를 지금 것과 비교하고, 고른 것만 받아들인다.
5
- *
6
- * 비교는 **저장 형식**으로 한다. 편집 중의 데이터는 씬 모델이지만, 후보는 서버에서
7
- * FigureSource 로 오고 기준 치수·디테일 등급처럼 부품 밖의 값도 함께 봐야 한다.
8
- *
9
- * 부품 편집 규칙은 여기 없다 — `part-edits.ts` 가 씬 모델 용어로 갖고 있다.
10
- */
11
-
12
- /**
13
- * 후보가 무엇을 바꾸자는 것인가.
14
- *
15
- * 후보를 통째로만 받게 두면 「몸통은 좋은데 모터 자리가 틀렸다」에 답할 수 없다 —
16
- * 그게 가장 흔한 반응이다. 그래서 후보를 **변경 목록**으로 본다.
17
- *
18
- * 부품은 이름으로 짝짓는다. `FigurePart.name` 이 저장되는 식별자이고 바인딩이
19
- * 그 이름을 가리키므로, 이름이 같으면 같은 부품이다.
20
- *
21
- * 기준 치수와 디테일 등급도 목록에 넣는다. 부품만 골라 받으면 새 부품이 옛 기준
22
- * 상자 밖으로 나가 **검증에 걸릴 수 있다** — 무엇을 함께 받아야 하는지 보여야 한다.
23
- */
24
- export type ChangeKind =
25
- | 'added'
26
- | 'changed'
27
- | 'removed'
28
- | 'base'
29
- | 'placement'
30
- | 'detail'
31
- | 'styleKit'
32
- | 'animations'
33
- | 'parameters'
34
- | 'capabilities'
35
- | 'joints'
36
-
37
- /**
38
- * 부품 밖에서 바뀔 수 있는 것 전부.
39
- *
40
- * 여기 없는 필드는 **조용히 버려진다** — 후보가 바꿔 왔어도 목록에 안 뜨고 받아도
41
- * 반영되지 않는다. 형식에 필드가 늘면 여기에 더해야 한다.
42
- *
43
- * 그 일이 실제로 났다(2026-09-15). `animations` · `capabilities` · `placement` 셋이 빠져 있었다.
44
- * 후보가 애니메이션만 더해 오면 변경 목록이 **비어서** 「n건 반영」 단추가 잠기고, 저작자는
45
- * 후보를 받지도 버리지도 못한 채 멈춘다. 다른 변경과 섞여 오면 단추는 눌리는데 애니메이션만
46
- * 말없이 사라진다. 그리고 `anchor` 는 도형 수준 필드였다가 형식에서 없어진 것인데(2026-09-10)
47
- * 여기 남아 있었다 — 이 목록이 형식을 따라가지 않은 것이 양쪽으로 드러난 셈이다.
48
- *
49
- * 같은 일이 `joints` 로 한 번 더 났다(2026-09-18). 형식에 관절이 서고 저작 화면도 지었는데 이
50
- * 목록만 그대로여서, 관절을 더해 온 후보는 변경 목록에 안 뜨고 받아도 관절이 사라졌다. 형식의
51
- * 칸을 세는 시험이 그것을 잡았다.
52
- *
53
- * 형식의 `FigureSource` 는 `type` · `parts` 를 빼면 이 일곱이 전부다. `type` 은 저장되는
54
- * 식별자라 후보가 바꿔 와도 받지 않고(`applyProposal` 참고), `version` 은 저작자가 고를 것이
55
- * 아니라 저장할 때 서버가 찍는다.
56
- */
57
- const FIGURE_FIELDS = ['base', 'placement', 'detailLevel', 'styleKit', 'animations', 'parameters', 'capabilities', 'joints'] as const
58
- const FIELD_KIND: Record<(typeof FIGURE_FIELDS)[number], ChangeKind> = {
59
- base: 'base',
60
- placement: 'placement',
61
- detailLevel: 'detail',
62
- styleKit: 'styleKit',
63
- animations: 'animations',
64
- parameters: 'parameters',
65
- capabilities: 'capabilities',
66
- joints: 'joints'
67
- }
68
-
69
- export interface ProposalChange {
70
- kind: ChangeKind
71
- /** 부품 변경이면 부품 이름. 그 밖이면 무엇이 바뀌는지. */
72
- key: string
73
- /** 부품 변경일 때 후보 쪽 부품. */
74
- part?: FigurePart
75
- }
76
-
77
- /** 부품 둘이 같은가. 순서까지 같은 JSON 으로 견준다 — 필드 순서는 만드는 쪽이 정한다. */
78
- function samePart(a: FigurePart, b: FigurePart): boolean {
79
- return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b))
80
- }
81
-
82
- /** 키 순서를 맞춰 견줄 수 있게 한다. */
83
- function sortKeys(value: unknown): unknown {
84
- if (Array.isArray(value)) return value.map(sortKeys)
85
- if (value && typeof value === 'object') {
86
- const out: Record<string, unknown> = {}
87
- for (const key of Object.keys(value as object).sort()) {
88
- out[key] = sortKeys((value as Record<string, unknown>)[key])
89
- }
90
- return out
91
- }
92
- return value
93
- }
94
-
95
- export function diffProposal(current: FigureSource | undefined, proposal: FigureSource): ProposalChange[] {
96
- const changes: ProposalChange[] = []
97
- const mine = new Map((current?.parts ?? []).map(part => [part.name, part]))
98
- const theirs = new Map(proposal.parts.map(part => [part.name, part]))
99
-
100
- for (const field of FIGURE_FIELDS) {
101
- if (!current) break
102
- const before = JSON.stringify(sortKeys(current[field]))
103
- const after = JSON.stringify(sortKeys(proposal[field]))
104
- if (before !== after) {
105
- changes.push({ kind: FIELD_KIND[field], key: field })
106
- }
107
- }
108
-
109
- for (const part of proposal.parts) {
110
- const old = mine.get(part.name)
111
- if (!old) changes.push({ kind: 'added', key: part.name, part })
112
- else if (!samePart(old, part)) changes.push({ kind: 'changed', key: part.name, part })
113
- }
114
- for (const part of current?.parts ?? []) {
115
- if (!theirs.has(part.name)) changes.push({ kind: 'removed', key: part.name })
116
- }
117
-
118
- return changes
119
- }
120
-
121
- /**
122
- * 고른 변경만 받는다.
123
- *
124
- * **타입 이름은 지금 것을 지킨다.** 만든 뒤에는 고칠 수 없는 저장 식별자이고,
125
- * 배치된 인스턴스가 그 이름으로 청사진을 찾는다. 후보가 다른 이름을 지어 왔어도
126
- * 그것 때문에 이미 놓인 것이 빈 자리가 되면 안 된다.
127
- *
128
- * `picked` 를 주지 않으면 전부 받는다 — 그것이 보통의 길이다.
129
- *
130
- * 더해지는 부품은 **뒤에 붙인다.** 후보가 놓은 자리에 끼워 넣으면, 일부만 골랐을
131
- * 때 순서가 어디서 온 것인지 알 수 없게 된다.
132
- */
133
- export function applyProposal(
134
- current: FigureSource | undefined,
135
- proposal: FigureSource,
136
- picked?: Set<string>
137
- ): FigureSource {
138
- const takes = (change: ProposalChange) => !picked || picked.has(change.key)
139
- const changes = diffProposal(current, proposal)
140
-
141
- if (!current) {
142
- return { ...proposal }
143
- }
144
-
145
- const removed = new Set(changes.filter(c => c.kind === 'removed' && takes(c)).map(c => c.key))
146
- const replaced = new Map(
147
- changes.filter(c => c.kind === 'changed' && takes(c)).map(c => [c.key, c.part as FigurePart])
148
- )
149
- const added = changes.filter(c => c.kind === 'added' && takes(c)).map(c => c.part as FigurePart)
150
-
151
- const parts = [
152
- ...current.parts.filter(part => !removed.has(part.name)).map(part => replaced.get(part.name) ?? part),
153
- ...added
154
- ]
155
-
156
- // 고른 필드만 후보 값으로 바꾼다. 안 고른 것은 지금 값이 남는다.
157
- const fields: Record<string, unknown> = {}
158
- for (const field of FIGURE_FIELDS) {
159
- const change = changes.find(c => c.key === field)
160
- if (change && takes(change)) {
161
- fields[field] = proposal[field]
162
- }
163
- }
164
-
165
- return { ...current, ...fields, parts } as FigureSource
166
- }
167
-
168
- /** 전부 받는다. */
169
- export function takeProposal(current: FigureSource | undefined, proposal: FigureSource): FigureSource {
170
- return applyProposal(current, proposal)
171
- }
@@ -1,28 +0,0 @@
1
- import type { FigureSource } from '@hatiolab/figure-model';
2
- import type { FigureGateFix, FigureProposal, ProposalFeedback } from '../types.js';
3
- /**
4
- * 한 번의 AI 후보 검토에만 존재하는 상태다.
5
- *
6
- * 후보는 정본이 아니다. 후보 원본·선택·메모·측정값을 한 값으로 묶어 두면 화면이
7
- * 수락/거절 중 일부만 이전 후보에서 남기는 일이 없다.
8
- */
9
- export interface ProposalSession {
10
- source: FigureSource;
11
- title: string;
12
- picked: Set<string>;
13
- note: string;
14
- metrics?: Pick<FigureProposal, 'grade' | 'triangles' | 'groups' | 'attempts' | 'quality'>;
15
- /**
16
- * The release-check fix this session was opened from, if any. The page translates the title from it;
17
- * this module stays free of i18n because node tests import it directly.
18
- */
19
- fix?: FigureGateFix;
20
- }
21
- /** 서버 후보를 검토 세션으로 연다. 저장 형식이 아니면 화면에 후보를 열지 않는다. */
22
- export declare function openAiProposal(current: FigureSource | undefined, proposal: FigureProposal): ProposalSession;
23
- /** 발행 게이트가 측정한 한-앵커 수정도 같은 후보 세션으로 연다. */
24
- export declare function openSizingFix(current: FigureSource, fix: FigureGateFix): ProposalSession | undefined;
25
- /** 후보 검토 중 사람의 선택을 뒤집는다. 기존 Set을 바꾸지 않아 Lit 상태 경계도 보존한다. */
26
- export declare function toggleProposalChange(session: ProposalSession, key: string): ProposalSession;
27
- /** 사람의 결정을, 다음 AI 요청에만 쓰는 제한된 피드백으로 만든다. */
28
- export declare function feedbackFromSession(current: FigureSource | undefined, session: ProposalSession, outcome: ProposalFeedback['outcome']): ProposalFeedback | undefined;