@things-factory/figure-service 10.1.244 → 10.1.246

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 (39) hide show
  1. package/README.md +19 -0
  2. package/dist-server/routers/figure-thumbnail-router.js +16 -1
  3. package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
  4. package/dist-server/service/figure/figure-ai-scenarios-run.d.ts +44 -0
  5. package/dist-server/service/figure/figure-ai-scenarios-run.js +94 -0
  6. package/dist-server/service/figure/figure-ai-scenarios-run.js.map +1 -0
  7. package/dist-server/service/figure/figure-ai-scenarios.d.ts +68 -0
  8. package/dist-server/service/figure/figure-ai-scenarios.js +814 -0
  9. package/dist-server/service/figure/figure-ai-scenarios.js.map +1 -0
  10. package/dist-server/service/figure/figure-context.js +3 -0
  11. package/dist-server/service/figure/figure-context.js.map +1 -1
  12. package/dist-server/service/figure/figure-llm-provider.d.ts +10 -0
  13. package/dist-server/service/figure/figure-llm-provider.js +19 -0
  14. package/dist-server/service/figure/figure-llm-provider.js.map +1 -0
  15. package/dist-server/service/figure/figure-mutation.js +19 -23
  16. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  17. package/dist-server/service/figure/figure-propose-steps.d.ts +5 -1
  18. package/dist-server/service/figure/figure-propose-steps.js +14 -2
  19. package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
  20. package/dist-server/service/figure/figure-query.d.ts +10 -2
  21. package/dist-server/service/figure/figure-query.js +59 -11
  22. package/dist-server/service/figure/figure-query.js.map +1 -1
  23. package/dist-server/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +8 -8
  25. package/scripts/figure-ai-scenarios-agent.cjs +98 -0
  26. package/server/routers/figure-thumbnail-router.ts +18 -2
  27. package/server/service/figure/figure-ai-scenarios-run.ts +127 -0
  28. package/server/service/figure/figure-ai-scenarios.live.test.ts +59 -0
  29. package/server/service/figure/figure-ai-scenarios.test.ts +77 -0
  30. package/server/service/figure/figure-ai-scenarios.ts +792 -0
  31. package/server/service/figure/figure-context.ts +3 -0
  32. package/server/service/figure/figure-llm-provider.ts +20 -0
  33. package/server/service/figure/figure-llm-smoke.test.ts +5 -68
  34. package/server/service/figure/figure-mutation.ts +17 -23
  35. package/server/service/figure/figure-propose-steps.test.ts +55 -0
  36. package/server/service/figure/figure-propose-steps.ts +14 -3
  37. package/server/service/figure/figure-public-db.test.ts +108 -0
  38. package/server/service/figure/figure-query.ts +55 -10
  39. package/server/service/figure/figure-thumbnail-db.test.ts +16 -0
package/README.md CHANGED
@@ -25,6 +25,25 @@
25
25
  바꾸면 오류 없이 빈 화면이 된다. 그래서 `FigurePatch` 에 `type` 이 없다 — 고칠 수 있는
26
26
  이름은 표시 이름(`name`)뿐이다.
27
27
 
28
+ ## AI 모델링 시나리오
29
+
30
+ AI 가 피규어를 만들고 고치는지 시나리오 묶음으로 점검한다(`server/service/figure/figure-ai-scenarios.ts`). 시나리오마다 요청, 출발 초안, 판정이 있고, 판정은 결과 피규어를 커널로 재어 빠진 것을 말한다. 곡선 배관·관 고치기·가늘어지는 상자·관절·반복·클립·크기 따르기·데이터 바인딩·대칭 다리를 다룬다.
31
+
32
+ - `yarn test` 에 드는 `figure-ai-scenarios.test.ts` 가 판정을 모델 없이 점검한다. 시나리오마다 모범 답은 통과하고, 커널은 받지만 요청을 놓친 답은 실패해야 한다.
33
+ - **도메인의 AI 에이전트로** (사람이 쓰는 것과 같은 모델·설정·키, 사용량은 그 도메인 기록에): 앱 폴더에서 돌린다. 키는 셸에도 저장소에도 두지 않는다.
34
+
35
+ ```bash
36
+ yarn workspace @things-factory/figure-service build:server
37
+ cd <operato-application>/packages/operato-office
38
+ NODE_ENV=development node <things-factory>/packages/figure-service/scripts/figure-ai-scenarios-agent.cjs --domain hatiolab
39
+ ```
40
+
41
+ `--agent <id>` 로 도메인 기본이 아닌 에이전트를, `--scenarios a,b` 로 몇 개만, `--repeat 3` 으로 여러 번 돌린다. DB 표는 건드리지 않는다(synchronize·migration 끔). sqlite 가 아닌 DB 는 `--allow-database` 일 때만, 운영 모드는 받지 않는다. 모두 통과하면 종료 코드 0, 아니면 1.
42
+ - 공급자 키로도 돌릴 수 있다: 저장소 루트에서 `ANTHROPIC_API_KEY=... yarn test:figure-ai-scenarios`. `FIGURE_LLM_PROVIDER`·`FIGURE_LLM_MODEL` 로 공급자와 모델을, `FIGURE_AI_SCENARIOS`·`FIGURE_AI_REPEAT` 로 고른다.
43
+ - 보고서는 `logs/figure-ai-scenarios/` 에 남고, 같은 공급자·모델의 지난 보고서와 견주어 나빠진 것과 나아진 것을 말한다.
44
+ - `FIGURE_LLM_PROVIDER=reference` 는 모델 대신 모범 답을 돌려준다. 키 없이 실행기와 보고서를 점검할 때 쓴다.
45
+ - 새 기능을 커널에 더하면 그 기능을 쓰는 시나리오도 더한다.
46
+
28
47
  ## 설계
29
48
 
30
49
  - `docs/design/figure-modules.md` — 모듈 설계와 착수 순서
@@ -10,6 +10,7 @@ const fs_1 = require("fs");
10
10
  const figure_thumbnail_store_js_1 = require("../service/figure/figure-thumbnail-store.js");
11
11
  const figure_recipe_templates_js_1 = require("../service/figure-recipe/figure-recipe-templates.js");
12
12
  const figure_public_js_1 = require("../service/figure/figure-public.js");
13
+ const figure_history_js_1 = require("../service/figure/figure-history.js");
13
14
  /**
14
15
  * 썸네일을 **그림으로** 내보내는 길.
15
16
  *
@@ -54,6 +55,15 @@ exports.figureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next)
54
55
  }
55
56
  await sendFigureThumbnail(context, next, context.params.id, domain);
56
57
  });
58
+ /** A public figure's picture as its latest release keeps it, on the figure's domain. */
59
+ async function releasedPictureOf(row) {
60
+ const release = await (0, shell_1.getRepository)(figure_history_js_1.FigureHistory).findOne({
61
+ where: { originalId: row.id },
62
+ order: { version: 'DESC' },
63
+ select: { id: true, thumbnailHash: true, updatedAt: true }
64
+ });
65
+ return release && { thumbnailHash: release.thumbnailHash, thumbnailUpdatedAt: release.updatedAt, domain: row.domain };
66
+ }
57
67
  /**
58
68
  * The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give
59
69
  * their own `files`.
@@ -64,7 +74,12 @@ async function sendFigureThumbnail(context, next, id, domain, files = (0, figure
64
74
  그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.
65
75
  */
66
76
  /* Another domain's public figure is read too (G2): its card is what a reader chooses it by. */
67
- const found = await (0, figure_public_js_1.readableFigure)(domain, id, { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, public: true, domain: { id: true } });
77
+ const row = await (0, figure_public_js_1.readableFigure)(domain, id, { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, public: true, domain: { id: true } });
78
+ /*
79
+ Another domain is shown the picture of the latest release, as its list is (2026-11-03 audit 1): the row's picture
80
+ may be of a draft not released.
81
+ */
82
+ const found = row && (0, figure_public_js_1.foreignPublic)(domain, row) ? await releasedPictureOf(row) : row;
68
83
  if (!found?.thumbnailHash) {
69
84
  context.status = 404;
70
85
  return;
@@ -1 +1 @@
1
- {"version":3,"file":"figure-thumbnail-router.js","sourceRoot":"","sources":["../../server/routers/figure-thumbnail-router.ts"],"names":[],"mappings":"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","sourcesContent":["import Router from 'koa-router'\nimport { In } from 'typeorm'\n\nimport { checkPermission } from '@things-factory/auth-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { readFileSync } from 'fs'\n\nimport { Figure } from '../service/figure/figure.js'\nimport { thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'\nimport { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'\nimport { readableFigure } from '../service/figure/figure-public.js'\n\n/**\n * 썸네일을 **그림으로** 내보내는 길.\n *\n * ## 왜 필요한가\n *\n * 썸네일은 attachment-base 가 내용 해시로 갖는다(ADR-0104 결정 6). 옛날에는 base64 문자열로 칸에 있었고, 그것을 목록 질의가\n * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은\n * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.\n *\n * 그림으로 내보내면 브라우저가 캐시하고, 부르는 쪽이 `?v=thumbnailUpdatedAt` 을 붙이므로 그림이\n * 다시 만들어진 것만 다시 받는다. 목록 질의에서는 `thumbnail` 을 아예 고르지 않는다.\n *\n * ## board 를 베끼면서 고친 것 셋\n *\n * `standalone-board-service-router` 의 `/thumbnail/:id` 가 본이지만 그대로 두면 안 되는 것들이 있다.\n *\n * 1. **길 이름이 겹친다.** 이 앱은 board 도 함께 싣는다(메뉴가 board-ui 를 끌어온다).\n * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.\n * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이\n * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.\n * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). 저장할 때 data URL 이 스스로\n * 말한 형식을 attachment 의 mimetype 으로 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.\n */\nexport const figureThumbnailRouter = new Router()\n\nfigureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n\n /*\n **질의와 같은 문을 쓴다.**\n\n `User.hasPrivilege('query','figure',…)` 만 보면 안 된다 — figure 질의는\n `@privilege(category: \"figure\", privilege: \"query\", domainOwnerGranted: true)` 로 선언되어 있고,\n 도메인 소유자는 `figure:query` 권한 줄이 없어도 통과한다. 그 허용을 여기서 빼면 목록은 보이는데\n 그림만 403 이 되어, 화면은 「그림이 아직 없다」고 거짓말한다. [확인함] 실제로 카드 14장이 전부\n 403 이었다.\n\n 조건을 손으로 다시 적지 않고 지시자가 쓰는 함수를 그대로 쓴다. 선언이 바뀌면 여기도 함께 바뀐다.\n */\n const allowed = await checkPermission(\n { category: 'figure', privilege: 'query', owner: true },\n user,\n domain,\n unsafeIP,\n prohibitedPrivileges\n )\n\n if (!allowed) {\n context.status = 403\n return\n }\n\n await sendFigureThumbnail(context, next, context.params.id, domain)\n})\n\n/**\n * The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give\n * their own `files`.\n */\nexport async function sendFigureThumbnail(\n context: any,\n next: () => Promise<any>,\n id: string,\n domain: { id: string; parentId?: string },\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<void> {\n /*\n 읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고\n 그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.\n */\n /* Another domain's public figure is read too (G2): its card is what a reader chooses it by. */\n const found = await readableFigure(domain, id, { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, public: true, domain: { id: true } })\n if (!found?.thumbnailHash) {\n context.status = 404\n return\n }\n\n /*\n 그림은 **그 figure 의 도메인**에서 찾는다 — 묻는 도메인이 아니다(판정 조건 4). 해시가 같아도 다른 도메인의 행을\n 집지 않는다. 물려받은 figure 의 그림은 부모 도메인에 있다.\n */\n const picture = await thumbnailAttachmentOf({ getRepository }, found.domain!.id, found.thumbnailHash)\n if (!picture) {\n context.status = 404\n return\n }\n\n /*\n **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면 안 된다 —\n attachment 의 공개 경로가 쓰는 `public` 을 따라 쓰지 않는다(판정 조건 1). 같은 내용은 같은 해시이므로 ETag 가\n 곧 내용이다. 부르는 쪽이 `?v=` 로 버전을 가리므로 오래 두어도 낡은 그림이 남지 않는다.\n */\n const ours = () => {\n context.type = picture.mimetype\n context.set('Cache-Control', 'private, max-age=31536000')\n context.set('ETag', `\"${found.thumbnailHash}\"`)\n if (found.thumbnailUpdatedAt) context.set('Last-Modified', new Date(found.thumbnailUpdatedAt).toUTCString())\n }\n ours()\n /* koa answers `fresh` only for a 2xx: before a body the status is still 404, and the check never matched. */\n context.status = 200\n if (context.fresh) {\n context.status = 304\n return\n }\n await files.send(context, picture.path!, next)\n /* The storage sets its own (S3 its ETag, attachment's Cache-Control): this door's are said last. */\n ours()\n}\n\n/**\n * A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the\n * templates themselves. The file changes only with a release, so a day's private cache is enough.\n */\nfigureThumbnailRouter.get('/figure-recipe-thumbnail/:name', async context => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n const allowed = await checkPermission({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges)\n if (!allowed) {\n context.status = 403\n return\n }\n const template = recipeTemplates().templates.find(t => t.recipe.name === context.params.name)\n if (!template?.thumbnail) {\n context.status = 404\n return\n }\n context.type = 'image/png'\n context.set('Cache-Control', 'private, max-age=86400')\n context.body = readFileSync(template.thumbnail)\n})\n"]}
1
+ {"version":3,"file":"figure-thumbnail-router.js","sourceRoot":"","sources":["../../server/routers/figure-thumbnail-router.ts"],"names":[],"mappings":"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","sourcesContent":["import Router from 'koa-router'\nimport { In } from 'typeorm'\n\nimport { checkPermission } from '@things-factory/auth-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { readFileSync } from 'fs'\n\nimport { Figure } from '../service/figure/figure.js'\nimport { thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'\nimport { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'\nimport { foreignPublic, readableFigure } from '../service/figure/figure-public.js'\nimport { FigureHistory } from '../service/figure/figure-history.js'\n\n/**\n * 썸네일을 **그림으로** 내보내는 길.\n *\n * ## 왜 필요한가\n *\n * 썸네일은 attachment-base 가 내용 해시로 갖는다(ADR-0104 결정 6). 옛날에는 base64 문자열로 칸에 있었고, 그것을 목록 질의가\n * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은\n * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.\n *\n * 그림으로 내보내면 브라우저가 캐시하고, 부르는 쪽이 `?v=thumbnailUpdatedAt` 을 붙이므로 그림이\n * 다시 만들어진 것만 다시 받는다. 목록 질의에서는 `thumbnail` 을 아예 고르지 않는다.\n *\n * ## board 를 베끼면서 고친 것 셋\n *\n * `standalone-board-service-router` 의 `/thumbnail/:id` 가 본이지만 그대로 두면 안 되는 것들이 있다.\n *\n * 1. **길 이름이 겹친다.** 이 앱은 board 도 함께 싣는다(메뉴가 board-ui 를 끌어온다).\n * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.\n * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이\n * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.\n * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). 저장할 때 data URL 이 스스로\n * 말한 형식을 attachment 의 mimetype 으로 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.\n */\nexport const figureThumbnailRouter = new Router()\n\nfigureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n\n /*\n **질의와 같은 문을 쓴다.**\n\n `User.hasPrivilege('query','figure',…)` 만 보면 안 된다 — figure 질의는\n `@privilege(category: \"figure\", privilege: \"query\", domainOwnerGranted: true)` 로 선언되어 있고,\n 도메인 소유자는 `figure:query` 권한 줄이 없어도 통과한다. 그 허용을 여기서 빼면 목록은 보이는데\n 그림만 403 이 되어, 화면은 「그림이 아직 없다」고 거짓말한다. [확인함] 실제로 카드 14장이 전부\n 403 이었다.\n\n 조건을 손으로 다시 적지 않고 지시자가 쓰는 함수를 그대로 쓴다. 선언이 바뀌면 여기도 함께 바뀐다.\n */\n const allowed = await checkPermission(\n { category: 'figure', privilege: 'query', owner: true },\n user,\n domain,\n unsafeIP,\n prohibitedPrivileges\n )\n\n if (!allowed) {\n context.status = 403\n return\n }\n\n await sendFigureThumbnail(context, next, context.params.id, domain)\n})\n\n/** A public figure's picture as its latest release keeps it, on the figure's domain. */\nasync function releasedPictureOf(row: Figure): Promise<Pick<Figure, 'thumbnailHash' | 'thumbnailUpdatedAt' | 'domain'> | null> {\n const release = await getRepository(FigureHistory).findOne({\n where: { originalId: row.id },\n order: { version: 'DESC' },\n select: { id: true, thumbnailHash: true, updatedAt: true }\n })\n return release && { thumbnailHash: release.thumbnailHash, thumbnailUpdatedAt: release.updatedAt, domain: row.domain }\n}\n\n/**\n * The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give\n * their own `files`.\n */\nexport async function sendFigureThumbnail(\n context: any,\n next: () => Promise<any>,\n id: string,\n domain: { id: string; parentId?: string },\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<void> {\n /*\n 읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고\n 그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.\n */\n /* Another domain's public figure is read too (G2): its card is what a reader chooses it by. */\n const row = await readableFigure(domain, id, { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, public: true, domain: { id: true } })\n /*\n Another domain is shown the picture of the latest release, as its list is (2026-11-03 audit 1): the row's picture\n may be of a draft not released.\n */\n const found = row && foreignPublic(domain, row) ? await releasedPictureOf(row) : row\n if (!found?.thumbnailHash) {\n context.status = 404\n return\n }\n\n /*\n 그림은 **그 figure 의 도메인**에서 찾는다 — 묻는 도메인이 아니다(판정 조건 4). 해시가 같아도 다른 도메인의 행을\n 집지 않는다. 물려받은 figure 의 그림은 부모 도메인에 있다.\n */\n const picture = await thumbnailAttachmentOf({ getRepository }, found.domain!.id, found.thumbnailHash)\n if (!picture) {\n context.status = 404\n return\n }\n\n /*\n **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면 안 된다 —\n attachment 의 공개 경로가 쓰는 `public` 을 따라 쓰지 않는다(판정 조건 1). 같은 내용은 같은 해시이므로 ETag 가\n 곧 내용이다. 부르는 쪽이 `?v=` 로 버전을 가리므로 오래 두어도 낡은 그림이 남지 않는다.\n */\n const ours = () => {\n context.type = picture.mimetype\n context.set('Cache-Control', 'private, max-age=31536000')\n context.set('ETag', `\"${found.thumbnailHash}\"`)\n if (found.thumbnailUpdatedAt) context.set('Last-Modified', new Date(found.thumbnailUpdatedAt).toUTCString())\n }\n ours()\n /* koa answers `fresh` only for a 2xx: before a body the status is still 404, and the check never matched. */\n context.status = 200\n if (context.fresh) {\n context.status = 304\n return\n }\n await files.send(context, picture.path!, next)\n /* The storage sets its own (S3 its ETag, attachment's Cache-Control): this door's are said last. */\n ours()\n}\n\n/**\n * A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the\n * templates themselves. The file changes only with a release, so a day's private cache is enough.\n */\nfigureThumbnailRouter.get('/figure-recipe-thumbnail/:name', async context => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n const allowed = await checkPermission({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges)\n if (!allowed) {\n context.status = 403\n return\n }\n const template = recipeTemplates().templates.find(t => t.recipe.name === context.params.name)\n if (!template?.thumbnail) {\n context.status = 404\n return\n }\n context.type = 'image/png'\n context.set('Cache-Control', 'private, max-age=86400')\n context.body = readFileSync(template.thumbnail)\n})\n"]}
@@ -0,0 +1,44 @@
1
+ import type { AIClient } from '@things-factory/ai-client-base';
2
+ import { type FigureAIScenario } from './figure-ai-scenarios';
3
+ export interface FigureAIScenarioRun {
4
+ id: string;
5
+ title: string;
6
+ round: number;
7
+ passed: boolean;
8
+ /** What the figure missed, or why none was made. */
9
+ misses: string[];
10
+ attempts?: number;
11
+ steps?: number;
12
+ seconds: number;
13
+ /** The steps the model answered -- the last one refused when none was taken -- to look at again without asking it. */
14
+ answer?: unknown[];
15
+ }
16
+ export interface FigureAIScenarioReport {
17
+ at: string;
18
+ /** Who answered: `agent:<domain>/<name>` or the provider, and its model. */
19
+ by: string;
20
+ model: string;
21
+ rounds: number;
22
+ passed: number;
23
+ total: number;
24
+ comparedWith?: string;
25
+ worse: string[];
26
+ better: string[];
27
+ runs: FigureAIScenarioRun[];
28
+ }
29
+ /** The scenarios a run asks: all, or those named. */
30
+ export declare const scenariosNamed: (names: readonly string[]) => FigureAIScenario[];
31
+ /** One scenario asked once: the figure made, judged by its check and the gate; a failure to make one said as it failed. */
32
+ export declare function runFigureAIScenario(client: AIClient, scenario: FigureAIScenario, round?: number): Promise<FigureAIScenarioRun>;
33
+ /**
34
+ * The report of a run, written to `folder` beside the earlier ones and compared with the last of the same answerer and
35
+ * model: a scenario that passed less often is worse, more often better. Returns the report and the lines that say it.
36
+ */
37
+ export declare function writeFigureAIScenarioReport(folder: string, who: {
38
+ by: string;
39
+ model: string;
40
+ }, scenarios: readonly FigureAIScenario[], runs: readonly FigureAIScenarioRun[], rounds: number): {
41
+ report: FigureAIScenarioReport;
42
+ file: string;
43
+ lines: string[];
44
+ };
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scenariosNamed = void 0;
4
+ exports.runFigureAIScenario = runFigureAIScenario;
5
+ exports.writeFigureAIScenarioReport = writeFigureAIScenarioReport;
6
+ /**
7
+ * AI 모델링 시나리오를 실제 모델로 돌리고 보고서를 남긴다 — figure-ai-scenarios.ts 의 요청마다 피규어를 만들거나 고치게
8
+ * 하고, 결과를 시나리오의 판정으로 잰다.
9
+ *
10
+ * 모델은 부르는 쪽이 준다: 도메인의 AI 에이전트(`aiClientFor`, scripts/figure-ai-scenarios-agent.cjs) -- 사람이 쓰는
11
+ * 것과 같은 모델·설정·키로, 사용량은 그 도메인에 남는다 -- 또는 공급자 키(figure-ai-scenarios.live.test.ts). 보고서는
12
+ * 같은 모델의 지난 보고서와 견주어 나빠진 것과 나아진 것을 말하고, 실패한 답을 남겨 비용 없이 다시 볼 수 있게 한다.
13
+ */
14
+ const fs_1 = require("fs");
15
+ const path_1 = require("path");
16
+ const figure_model_1 = require("@hatiolab/figure-model");
17
+ const figure_ai_scenarios_1 = require("./figure-ai-scenarios");
18
+ const figure_propose_steps_1 = require("./figure-propose-steps");
19
+ /** The scenarios a run asks: all, or those named. */
20
+ const scenariosNamed = (names) => (names.length ? figure_ai_scenarios_1.FIGURE_AI_SCENARIOS.filter(s => names.includes(s.id)) : figure_ai_scenarios_1.FIGURE_AI_SCENARIOS);
21
+ exports.scenariosNamed = scenariosNamed;
22
+ /** One scenario asked once: the figure made, judged by its check and the gate; a failure to make one said as it failed. */
23
+ async function runFigureAIScenario(client, scenario, round = 1) {
24
+ const started = Date.now();
25
+ const base = scenario.base?.();
26
+ const at = { id: scenario.id, title: scenario.title, round };
27
+ try {
28
+ const made = await (0, figure_propose_steps_1.proposeFigureSteps)({ client, prompt: scenario.prompt, ...(base ? { base } : { name: scenario.name ?? 'Figure' }) });
29
+ const gate = (0, figure_model_1.inspectFigureAsset)(made.source).violations.map(v => `gate: ${v.code ?? JSON.stringify(v)}`);
30
+ const misses = [...gate, ...scenario.check(made.source, base ?? (0, figure_model_1.createFigureAsset)(scenario.name ?? 'Figure'))];
31
+ return { ...at, passed: !misses.length, misses, attempts: made.attempts, steps: made.steps.length, seconds: (Date.now() - started) / 1000, answer: made.steps };
32
+ }
33
+ catch (e) {
34
+ const reasons = e instanceof figure_propose_steps_1.ProposeFigureFailure ? [e.message, ...e.reasons] : [e instanceof Error ? e.message : String(e)];
35
+ const refused = e instanceof figure_propose_steps_1.ProposeFigureFailure ? e.lastAnswer?.steps : undefined;
36
+ return { ...at, passed: false, misses: [`not made: ${reasons.join(' / ')}`], seconds: (Date.now() - started) / 1000, ...(refused ? { answer: refused } : {}) };
37
+ }
38
+ }
39
+ const fileSafe = (text) => text.replace(/[^a-zA-Z0-9.-]/g, '_');
40
+ /** The last report written by the same answerer and model in `folder`. */
41
+ function lastReport(folder, suffix) {
42
+ try {
43
+ const file = (0, fs_1.readdirSync)(folder)
44
+ .filter(f => f.endsWith(suffix))
45
+ .sort()
46
+ .at(-1);
47
+ return file ? { file, runs: JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(folder, file), 'utf8')).runs } : undefined;
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ /**
54
+ * The report of a run, written to `folder` beside the earlier ones and compared with the last of the same answerer and
55
+ * model: a scenario that passed less often is worse, more often better. Returns the report and the lines that say it.
56
+ */
57
+ function writeFigureAIScenarioReport(folder, who, scenarios, runs, rounds) {
58
+ const suffix = `-${fileSafe(who.by)}-${fileSafe(who.model)}.json`;
59
+ const before = lastReport(folder, suffix);
60
+ const rate = (id, list) => {
61
+ const mine = list.filter(r => r.id === id);
62
+ return mine.length ? mine.filter(r => r.passed).length / mine.length : undefined;
63
+ };
64
+ const compared = scenarios.map(s => ({ id: s.id, now: rate(s.id, runs), was: before ? rate(s.id, before.runs) : undefined }));
65
+ const worse = compared.filter(c => c.was !== undefined && c.now !== undefined && c.now < c.was).map(c => c.id);
66
+ const better = compared.filter(c => c.was !== undefined && c.now !== undefined && c.now > c.was).map(c => c.id);
67
+ const report = {
68
+ at: new Date().toISOString(),
69
+ ...who,
70
+ rounds,
71
+ passed: runs.filter(r => r.passed).length,
72
+ total: runs.length,
73
+ ...(before ? { comparedWith: before.file } : {}),
74
+ worse,
75
+ better,
76
+ runs: [...runs]
77
+ };
78
+ (0, fs_1.mkdirSync)(folder, { recursive: true });
79
+ const file = (0, path_1.join)(folder, `${report.at.replace(/[:.]/g, '-')}${suffix}`);
80
+ (0, fs_1.writeFileSync)(file, JSON.stringify(report, null, 2));
81
+ const lines = [
82
+ `AI 모델링 시나리오 ${report.passed}/${report.total} 통과 — ${who.by} ${who.model}${rounds > 1 ? ` × ${rounds}` : ''}`,
83
+ ...scenarios.map(s => {
84
+ const mine = runs.filter(r => r.id === s.id);
85
+ const ok = mine.filter(r => r.passed).length;
86
+ const miss = mine.find(r => !r.passed)?.misses.slice(0, 2).join('; ');
87
+ return ` ${ok === mine.length ? '✔' : '✖'} ${s.id} ${ok}/${mine.length}${miss ? ` — ${miss}` : ''}`;
88
+ }),
89
+ ...(before ? [` 지난 보고서 ${before.file} 대비 — 나빠짐: ${worse.join(', ') || '없음'} · 나아짐: ${better.join(', ') || '없음'}`] : []),
90
+ ` 보고서: ${file}`
91
+ ];
92
+ return { report, file, lines };
93
+ }
94
+ //# sourceMappingURL=figure-ai-scenarios-run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-ai-scenarios-run.js","sourceRoot":"","sources":["../../../server/service/figure/figure-ai-scenarios-run.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * AI 모델링 시나리오를 실제 모델로 돌리고 보고서를 남긴다 — figure-ai-scenarios.ts 의 요청마다 피규어를 만들거나 고치게\n * 하고, 결과를 시나리오의 판정으로 잰다.\n *\n * 모델은 부르는 쪽이 준다: 도메인의 AI 에이전트(`aiClientFor`, scripts/figure-ai-scenarios-agent.cjs) -- 사람이 쓰는\n * 것과 같은 모델·설정·키로, 사용량은 그 도메인에 남는다 -- 또는 공급자 키(figure-ai-scenarios.live.test.ts). 보고서는\n * 같은 모델의 지난 보고서와 견주어 나빠진 것과 나아진 것을 말하고, 실패한 답을 남겨 비용 없이 다시 볼 수 있게 한다.\n */\nimport { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'\nimport { join } from 'path'\n\nimport type { AIClient } from '@things-factory/ai-client-base'\nimport { createFigureAsset, inspectFigureAsset } from '@hatiolab/figure-model'\n\nimport { FIGURE_AI_SCENARIOS, type FigureAIScenario } from './figure-ai-scenarios'\nimport { ProposeFigureFailure, proposeFigureSteps } from './figure-propose-steps'\n\nexport interface FigureAIScenarioRun {\n id: string\n title: string\n round: number\n passed: boolean\n /** What the figure missed, or why none was made. */\n misses: string[]\n attempts?: number\n steps?: number\n seconds: number\n /** The steps the model answered -- the last one refused when none was taken -- to look at again without asking it. */\n answer?: unknown[]\n}\n\nexport interface FigureAIScenarioReport {\n at: string\n /** Who answered: `agent:<domain>/<name>` or the provider, and its model. */\n by: string\n model: string\n rounds: number\n passed: number\n total: number\n comparedWith?: string\n worse: string[]\n better: string[]\n runs: FigureAIScenarioRun[]\n}\n\n/** The scenarios a run asks: all, or those named. */\nexport const scenariosNamed = (names: readonly string[]) => (names.length ? FIGURE_AI_SCENARIOS.filter(s => names.includes(s.id)) : FIGURE_AI_SCENARIOS)\n\n/** One scenario asked once: the figure made, judged by its check and the gate; a failure to make one said as it failed. */\nexport async function runFigureAIScenario(client: AIClient, scenario: FigureAIScenario, round = 1): Promise<FigureAIScenarioRun> {\n const started = Date.now()\n const base = scenario.base?.()\n const at = { id: scenario.id, title: scenario.title, round }\n try {\n const made = await proposeFigureSteps({ client, prompt: scenario.prompt, ...(base ? { base } : { name: scenario.name ?? 'Figure' }) })\n const gate = inspectFigureAsset(made.source).violations.map(v => `gate: ${(v as { code?: string }).code ?? JSON.stringify(v)}`)\n const misses = [...gate, ...scenario.check(made.source, base ?? createFigureAsset(scenario.name ?? 'Figure'))]\n return { ...at, passed: !misses.length, misses, attempts: made.attempts, steps: made.steps.length, seconds: (Date.now() - started) / 1000, answer: made.steps }\n } catch (e) {\n const reasons = e instanceof ProposeFigureFailure ? [e.message, ...e.reasons] : [e instanceof Error ? e.message : String(e)]\n const refused = e instanceof ProposeFigureFailure ? (e.lastAnswer as { steps?: unknown[] } | undefined)?.steps : undefined\n return { ...at, passed: false, misses: [`not made: ${reasons.join(' / ')}`], seconds: (Date.now() - started) / 1000, ...(refused ? { answer: refused } : {}) }\n }\n}\n\nconst fileSafe = (text: string) => text.replace(/[^a-zA-Z0-9.-]/g, '_')\n\n/** The last report written by the same answerer and model in `folder`. */\nfunction lastReport(folder: string, suffix: string): { file: string; runs: FigureAIScenarioRun[] } | undefined {\n try {\n const file = readdirSync(folder)\n .filter(f => f.endsWith(suffix))\n .sort()\n .at(-1)\n return file ? { file, runs: JSON.parse(readFileSync(join(folder, file), 'utf8')).runs } : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * The report of a run, written to `folder` beside the earlier ones and compared with the last of the same answerer and\n * model: a scenario that passed less often is worse, more often better. Returns the report and the lines that say it.\n */\nexport function writeFigureAIScenarioReport(\n folder: string,\n who: { by: string; model: string },\n scenarios: readonly FigureAIScenario[],\n runs: readonly FigureAIScenarioRun[],\n rounds: number\n): { report: FigureAIScenarioReport; file: string; lines: string[] } {\n const suffix = `-${fileSafe(who.by)}-${fileSafe(who.model)}.json`\n const before = lastReport(folder, suffix)\n const rate = (id: string, list: readonly FigureAIScenarioRun[]) => {\n const mine = list.filter(r => r.id === id)\n return mine.length ? mine.filter(r => r.passed).length / mine.length : undefined\n }\n const compared = scenarios.map(s => ({ id: s.id, now: rate(s.id, runs), was: before ? rate(s.id, before.runs) : undefined }))\n const worse = compared.filter(c => c.was !== undefined && c.now !== undefined && c.now < c.was).map(c => c.id)\n const better = compared.filter(c => c.was !== undefined && c.now !== undefined && c.now > c.was).map(c => c.id)\n const report: FigureAIScenarioReport = {\n at: new Date().toISOString(),\n ...who,\n rounds,\n passed: runs.filter(r => r.passed).length,\n total: runs.length,\n ...(before ? { comparedWith: before.file } : {}),\n worse,\n better,\n runs: [...runs]\n }\n mkdirSync(folder, { recursive: true })\n const file = join(folder, `${report.at.replace(/[:.]/g, '-')}${suffix}`)\n writeFileSync(file, JSON.stringify(report, null, 2))\n const lines = [\n `AI 모델링 시나리오 ${report.passed}/${report.total} 통과 — ${who.by} ${who.model}${rounds > 1 ? ` × ${rounds}` : ''}`,\n ...scenarios.map(s => {\n const mine = runs.filter(r => r.id === s.id)\n const ok = mine.filter(r => r.passed).length\n const miss = mine.find(r => !r.passed)?.misses.slice(0, 2).join('; ')\n return ` ${ok === mine.length ? '✔' : '✖'} ${s.id} ${ok}/${mine.length}${miss ? ` — ${miss}` : ''}`\n }),\n ...(before ? [` 지난 보고서 ${before.file} 대비 — 나빠짐: ${worse.join(', ') || '없음'} · 나아짐: ${better.join(', ') || '없음'}`] : []),\n ` 보고서: ${file}`\n ]\n return { report, file, lines }\n}\n"]}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * AI 모델링 시나리오 — what a person asks the figure AI for, and what the figure must then be (2026-11-03, 사용자 「여러가지
3
+ * 모델링 시나리오를 구성해서 AI를 통한 모델링이 잘 구현되는지 지속적으로 점검할 수 있도록 하라」).
4
+ *
5
+ * Each scenario is a request in a person's words, the draft it is asked on (none for a new figure), and a check that
6
+ * says what the figure that came back misses -- read from the figure by the kernel (its parts' shapes, fastenings,
7
+ * motion, clips, data), never from the steps the model wrote, so any way of making it that the kernel takes passes.
8
+ * Each also holds a reference answer the check must pass and a wrong one it must fail: the checks are themselves held to
9
+ * known verdicts without a provider (figure-ai-scenarios.test.ts). The live run (figure-ai-scenarios.live.test.ts) asks a
10
+ * real model for each and writes a report to compare runs by.
11
+ *
12
+ * The scenarios are spread over what the kernel can say (ADR-0100 shapes and §16's taper and pipe, ADR-0093 fastenings,
13
+ * ADR-0159 joints, repeats, clips, the figure's size, data bindings, mirrored chains), new figures and revisions both.
14
+ */
15
+ import { type FigureAsset } from '@hatiolab/figure-model';
16
+ type Dynamic = any;
17
+ type Axis = 'x' | 'y' | 'z';
18
+ export interface FigureAIScenario {
19
+ id: string;
20
+ /** What it checks, in a few words. */
21
+ title: string;
22
+ /** The parts of the kernel it reaches: what a failure points at. */
23
+ covers: string[];
24
+ /** The request, as a person types it. */
25
+ prompt: string;
26
+ /** The draft it is asked on; absent, a new figure named `name`. */
27
+ base?: () => FigureAsset;
28
+ name?: string;
29
+ /** What the figure that came back misses; empty when it is what was asked. `before` is the draft, for a revision. */
30
+ check: (after: FigureAsset, before?: FigureAsset) => string[];
31
+ /** An answer the check must pass, and one the kernel takes that the check must fail. */
32
+ reference: unknown[];
33
+ wrong: unknown[];
34
+ }
35
+ export interface ScenarioPart {
36
+ id: string;
37
+ /** The shape's kind, as figure-model describes it: rectangle, ellipse, taper, tapered, sweep … */
38
+ kind: string;
39
+ /** Where it stands in the figure, at its default size and pose: its box's centre and extent. */
40
+ centre: Record<Axis, number>;
41
+ extent: Record<Axis, number>;
42
+ colour: string;
43
+ /** What it is fastened to: a part, the plane, or nothing. */
44
+ on?: string;
45
+ /** Its joint's axes' types, in order; none for a fastening. */
46
+ motion: string[];
47
+ /** Its joint's controls, by axis. */
48
+ controls: string[];
49
+ limits: Array<{
50
+ lower?: number;
51
+ upper?: number;
52
+ }>;
53
+ /** How many copies its row makes; 1 for a part not in a row. */
54
+ copies: number;
55
+ shape: Dynamic;
56
+ node: Dynamic;
57
+ }
58
+ /** Every part of a figure, as the kernel evaluates it. */
59
+ export declare function scenarioPartsOf(asset: FigureAsset): ScenarioPart[];
60
+ /** A mixer: a body standing on the floor, a port fastened to its front. */
61
+ export declare function mixerDraft(): FigureAsset;
62
+ /**
63
+ * What "make the port twice as wide and give it an accent colour" asks of the mixer, said as what is missing: the port
64
+ * twice as wide (X), its height and depth kept, a colour it did not have, and the body left as it was.
65
+ */
66
+ export declare function portRevisionMisses(before: FigureAsset, after: FigureAsset): string[];
67
+ export declare const FIGURE_AI_SCENARIOS: FigureAIScenario[];
68
+ export {};