@cartanova/qgrid-cli 2.7.0 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/dist/_virtual/_rolldown/runtime.js +20 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +17 -6
- package/bundle/dist/application/qgrid/qgrid.frame.js +1 -1
- package/bundle/dist/application/qgrid/schema-prompt.js +240 -0
- package/bundle/dist/application/qgrid/tool-emulation.js +7 -5
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/2020.js +112 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/codegen/code.js +140 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/codegen/index.js +676 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/codegen/scope.js +143 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/errors.js +104 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/index.js +225 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/names.js +30 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/ref_error.js +20 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/resolve.js +135 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/rules.js +63 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/util.js +148 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/validate/applicability.js +25 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/validate/boolSchema.js +50 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/validate/dataType.js +174 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/validate/defaults.js +33 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/validate/index.js +435 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/validate/keyword.js +115 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/compile/validate/subschema.js +74 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/core.js +585 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/refs/data.js +20 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/refs/json-schema-2020-12/index.js +44 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/refs/json-schema-2020-12/meta/applicator.js +52 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/refs/json-schema-2020-12/meta/content.js +22 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/refs/json-schema-2020-12/meta/core.js +53 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/refs/json-schema-2020-12/meta/format-annotation.js +18 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/refs/json-schema-2020-12/meta/meta-data.js +38 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/refs/json-schema-2020-12/meta/unevaluated.js +21 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/refs/json-schema-2020-12/meta/validation.js +87 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/refs/json-schema-2020-12/schema.js +61 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/runtime/equal.js +14 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/runtime/ucs2length.js +27 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/runtime/uri.js +14 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/runtime/validation_error.js +18 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/additionalItems.js +59 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/additionalProperties.js +98 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/allOf.js +31 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/anyOf.js +18 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/contains.js +91 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/dependencies.js +89 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/dependentSchemas.js +18 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/if.js +64 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/index.js +62 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/items.js +61 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/items2020.js +36 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/not.js +33 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/oneOf.js +53 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/patternProperties.js +63 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/prefixItems.js +19 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/properties.js +54 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/propertyNames.js +43 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/applicator/thenElse.js +19 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/code.js +132 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/core/id.js +16 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/core/index.js +24 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/core/ref.js +114 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/discriminator/index.js +103 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/discriminator/types.js +16 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/draft2020.js +37 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/dynamic/dynamicAnchor.js +48 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/dynamic/dynamicRef.js +47 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/dynamic/index.js +24 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/dynamic/recursiveAnchor.js +22 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/dynamic/recursiveRef.js +17 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/format/format.js +94 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/format/index.js +12 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/metadata.js +25 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/next.js +21 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/unevaluated/index.js +15 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/unevaluated/unevaluatedItems.js +48 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/unevaluated/unevaluatedProperties.js +61 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/const.js +29 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/dependentRequired.js +19 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/enum.js +51 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/index.js +50 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/limitContains.js +20 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/limitItems.js +30 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/limitLength.js +35 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/limitNumber.js +49 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/limitProperties.js +30 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/multipleOf.js +29 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/pattern.js +40 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/required.js +74 -0
- package/bundle/dist/node_modules/.pnpm/ajv@8.20.0/node_modules/ajv/dist/vocabularies/validation/uniqueItems.js +72 -0
- package/bundle/dist/node_modules/.pnpm/fast-deep-equal@3.1.3/node_modules/fast-deep-equal/index.js +35 -0
- package/bundle/dist/node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/index.js +238 -0
- package/bundle/dist/node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/lib/schemes.js +189 -0
- package/bundle/dist/node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/lib/utils.js +284 -0
- package/bundle/dist/node_modules/.pnpm/json-schema-traverse@1.0.0/node_modules/json-schema-traverse/index.js +80 -0
- package/bundle/dist/testing/schema-bench.js +0 -0
- package/bundle/dist/utils/providers/anthropic/fence-strip.js +83 -0
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +3 -5
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +250 -34
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +36 -5
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +120 -153
- package/bundle/src/application/qgrid/qgrid.frame.ts +2 -0
- package/bundle/src/application/qgrid/schema-prompt.test.ts +166 -0
- package/bundle/src/application/qgrid/schema-prompt.ts +293 -0
- package/bundle/src/application/qgrid/tool-emulation.test.ts +36 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +19 -7
- package/bundle/src/testing/schema-bench.test.ts +358 -0
- package/bundle/src/testing/schema-bench.ts +352 -0
- package/bundle/src/utils/providers/anthropic/fence-strip.test.ts +122 -0
- package/bundle/src/utils/providers/anthropic/fence-strip.ts +105 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +2 -8
- package/bundle/web-dist/client/assets/{index-C24D5PiN.js → index-C0594sjh.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-CIlZG6A3.js → logs-BotUcOEB.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-DZFBMxKl.js → monit-kk3vnGvu.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-C3wlSNBL.js → routes-J4bnHVfl.js} +1 -1
- package/bundle/web-dist/client/assets/{settings-ClRDWO-8.js → settings-CAAQSIyg.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BP7U8yyJ.js → show-BryNvjUT.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-DfQeULnJ.js → tokens-DDrATjGA.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-CY-ASCfL.js → use-oauth-login-flow-CptleCFM.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +1 -1
- package/package.json +1 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +20 -15
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +31 -14
- package/skills/qgrid/references/decision-rationale.md +13 -3
- package/skills/qgrid/references/prompt-cache-and-usage.md +4 -1
- package/skills/qgrid/references/provider-runtime-differences.md +3 -3
- package/skills/qgrid/references/tool-calling-and-multiturn.md +16 -6
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema-delivery 준수율 벤치의 순수 로직 (SON-532).
|
|
3
|
+
*
|
|
4
|
+
* 러너(scripts/schema-delivery-bench.ts)는 qgrid HTTP API 로 실제 요청을 발사하고,
|
|
5
|
+
* 이 모듈은 응답 분류·집계·checkpoint 를 담당한다 — 벤치 결과의 신뢰가 집계 정확성에
|
|
6
|
+
* 걸려 있으므로 이 부분만 단위 테스트로 고정한다.
|
|
7
|
+
*
|
|
8
|
+
* 실패 분류는 서로 배타적인 3계층이다 (Codex 검토 반영 — 중복 판정 금지):
|
|
9
|
+
* 1. syntax — JSON.parse 실패
|
|
10
|
+
* 2. contract — 파싱은 됐으나 계약(필수 키·fixture validate) 불통과. 필드 누락은 여기.
|
|
11
|
+
* 3. (별도 축) topLevel — 래퍼 키(GEN-359 병리)·펜스 잔존·프로즈. 형태 축이라
|
|
12
|
+
* syntax/contract 와 겹칠 수 있고, 게이트 계수는 이 축 기준이다.
|
|
13
|
+
*/
|
|
14
|
+
import Ajv2020, { type ValidateFunction } from "ajv/dist/2020";
|
|
15
|
+
|
|
16
|
+
export type BenchMode = "generate" | "stream";
|
|
17
|
+
|
|
18
|
+
export interface BenchClassifyOptions {
|
|
19
|
+
/**
|
|
20
|
+
* 응답이 평문 텍스트인 계약 (tools-only 의 최종 answer 등). JSON 문법·형태 축 판정을
|
|
21
|
+
* 건너뛴다 — 평문 answer 를 JSON.parse 하면 정상 응답이 syntax fail 로 둔갑한다.
|
|
22
|
+
*/
|
|
23
|
+
plainText?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* fixture 의 전체 JSON Schema. 지정하면 validateAgainstSchema 로 **전체 트리**를
|
|
26
|
+
* 검증한다 — top-level 키 존재만 보면 `{"scenes":[{},{},{}]}` 같은 속 빈 응답이
|
|
27
|
+
* 통과한다(SON-495 교훈: 상위 키 판정은 쓰레기를 통과시킨다).
|
|
28
|
+
*/
|
|
29
|
+
schema?: unknown;
|
|
30
|
+
/** 최상위에 반드시 있어야 하는 키 (schema 미지정 시의 최소 검증) */
|
|
31
|
+
requiredTopLevelKeys?: string[];
|
|
32
|
+
/** 허용되는 최상위 키 전체 — 래퍼 키 판정에 사용 */
|
|
33
|
+
allowedTopLevelKeys?: string[];
|
|
34
|
+
/** fixture 별 추가 계약 검증. 실패 사유 문자열을 반환하면 contract fail. */
|
|
35
|
+
validate?: (parsed: unknown, rawText: string) => string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 표준 JSON Schema 검증 (Ajv, Draft 2020-12). 위반 경로 목록을 반환한다.
|
|
40
|
+
*
|
|
41
|
+
* 한때 축약 검증기를 썼으나 2차 검토가 기각했다 — 미지원 keyword 를 무시하고 oneOf 를
|
|
42
|
+
* anyOf 처럼 다뤄 false pass 가 났고, 실제로 e2e 4/4 통과 보고가 Ajv 재검증에서 2/4 로
|
|
43
|
+
* 뒤집혔다(anyOf 브랜치 내부의 enum 위반을 통째로 건너뜀). 임의 스키마(BENCH_SCHEMA_FILE)
|
|
44
|
+
* 를 게이트로 쓰려면 표준 검증기가 유일하게 신뢰 가능하다.
|
|
45
|
+
*/
|
|
46
|
+
export function validateAgainstSchema(node: unknown, schema: unknown): string[] {
|
|
47
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) return [];
|
|
48
|
+
const validate = compileSchema(schema as Record<string, unknown>);
|
|
49
|
+
if (validate(node)) return [];
|
|
50
|
+
return (validate.errors ?? []).map(
|
|
51
|
+
(e) =>
|
|
52
|
+
`${e.instancePath || "$"}: ${e.message ?? e.keyword}${e.keyword === "enum" ? ` (${JSON.stringify((e.params as { allowedValues?: unknown[] }).allowedValues?.slice(0, 8))})` : ""}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const compiledSchemas = new WeakMap<object, ValidateFunction>();
|
|
57
|
+
let ajvInstance: Ajv2020 | undefined;
|
|
58
|
+
|
|
59
|
+
function compileSchema(schema: Record<string, unknown>): ValidateFunction {
|
|
60
|
+
const cached = compiledSchemas.get(schema);
|
|
61
|
+
if (cached) return cached;
|
|
62
|
+
// strict:false — 소비자 스키마의 비표준 어노테이션(description 변형 등)을 컴파일
|
|
63
|
+
// 에러로 만들지 않는다. 검증 시맨틱은 표준 그대로다.
|
|
64
|
+
ajvInstance ??= new Ajv2020({ strict: false, allErrors: true });
|
|
65
|
+
// $id 가 있는 스키마를 두 번 compile 하면 Ajv 가 중복 등록으로 던진다 — WeakMap 캐시가
|
|
66
|
+
// 같은 객체의 재컴파일을 막고, 다른 객체로 온 같은 $id 는 인스턴스를 새로 만들어 회피한다.
|
|
67
|
+
let validate: ValidateFunction;
|
|
68
|
+
try {
|
|
69
|
+
validate = ajvInstance.compile(schema);
|
|
70
|
+
} catch {
|
|
71
|
+
ajvInstance = new Ajv2020({ strict: false, allErrors: true });
|
|
72
|
+
validate = ajvInstance.compile(schema);
|
|
73
|
+
}
|
|
74
|
+
compiledSchemas.set(schema, validate);
|
|
75
|
+
return validate;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface TopLevelViolations {
|
|
79
|
+
/** 최상위가 허용 키 목록 밖의 단일 키로 감싸져 있음 — GEN-359 래퍼 키 병리 */
|
|
80
|
+
wrapperKey: boolean;
|
|
81
|
+
/** 서버 스트리핑 후에도 ``` 가 양끝에 남아 있음 */
|
|
82
|
+
fenceResidue: boolean;
|
|
83
|
+
/** JSON 이 아닌 산문으로 시작 */
|
|
84
|
+
prose: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface BenchClassification {
|
|
88
|
+
syntax: "ok" | "fail";
|
|
89
|
+
/** syntax fail 이면 계약 검증 자체가 불가 — "n/a" */
|
|
90
|
+
contract: "ok" | "fail" | "n/a";
|
|
91
|
+
contractDetail?: string;
|
|
92
|
+
topLevel: TopLevelViolations;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function classifyBenchText(
|
|
96
|
+
text: string,
|
|
97
|
+
options: BenchClassifyOptions = {},
|
|
98
|
+
): BenchClassification {
|
|
99
|
+
const trimmed = text.trim();
|
|
100
|
+
const fenceResidue = trimmed.startsWith("```") || trimmed.endsWith("```");
|
|
101
|
+
|
|
102
|
+
if (options.plainText) {
|
|
103
|
+
const reason = options.validate?.(text, text);
|
|
104
|
+
return {
|
|
105
|
+
syntax: "ok",
|
|
106
|
+
contract: reason === undefined ? "ok" : "fail",
|
|
107
|
+
...(reason === undefined ? {} : { contractDetail: reason }),
|
|
108
|
+
topLevel: { wrapperKey: false, fenceResidue, prose: false },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let parsed: unknown;
|
|
113
|
+
let syntax: "ok" | "fail" = "ok";
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(text);
|
|
116
|
+
} catch {
|
|
117
|
+
syntax = "fail";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const prose = syntax === "fail" && !fenceResidue && !/^[{[]/.test(trimmed);
|
|
121
|
+
|
|
122
|
+
let wrapperKey = false;
|
|
123
|
+
let contract: BenchClassification["contract"] = "n/a";
|
|
124
|
+
let contractDetail: string | undefined;
|
|
125
|
+
|
|
126
|
+
if (syntax === "ok") {
|
|
127
|
+
const record =
|
|
128
|
+
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
129
|
+
? (parsed as Record<string, unknown>)
|
|
130
|
+
: undefined;
|
|
131
|
+
|
|
132
|
+
if (record && options.allowedTopLevelKeys) {
|
|
133
|
+
const keys = Object.keys(record);
|
|
134
|
+
const allowed = new Set(options.allowedTopLevelKeys);
|
|
135
|
+
wrapperKey =
|
|
136
|
+
keys.length === 1 &&
|
|
137
|
+
!allowed.has(keys[0]!) &&
|
|
138
|
+
typeof record[keys[0]!] === "object" &&
|
|
139
|
+
record[keys[0]!] !== null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
contract = "ok";
|
|
143
|
+
if (record === undefined) {
|
|
144
|
+
contract = "fail";
|
|
145
|
+
contractDetail = "top-level is not an object";
|
|
146
|
+
} else if (options.schema !== undefined) {
|
|
147
|
+
const violations = validateAgainstSchema(parsed, options.schema);
|
|
148
|
+
if (violations.length > 0) {
|
|
149
|
+
contract = "fail";
|
|
150
|
+
contractDetail = `${violations.length} schema violations: ${violations.slice(0, 5).join("; ")}`;
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
const missing = (options.requiredTopLevelKeys ?? []).filter(
|
|
154
|
+
(key) => !Object.hasOwn(record, key),
|
|
155
|
+
);
|
|
156
|
+
if (missing.length > 0) {
|
|
157
|
+
contract = "fail";
|
|
158
|
+
contractDetail = `missing required keys: ${missing.join(", ")}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (contract === "ok" && options.validate) {
|
|
162
|
+
const reason = options.validate(parsed, text);
|
|
163
|
+
if (reason !== undefined) {
|
|
164
|
+
contract = "fail";
|
|
165
|
+
contractDetail = reason;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { syntax, contract, contractDetail, topLevel: { wrapperKey, fenceResidue, prose } };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── 레코드·checkpoint ──
|
|
174
|
+
|
|
175
|
+
export interface BenchRecord {
|
|
176
|
+
fixture: string;
|
|
177
|
+
/**
|
|
178
|
+
* fixture 내용(prompt·schema·tools)의 지문. checkpoint 동일성에 포함된다 —
|
|
179
|
+
* 이름만으로 식별하면 fixture 를 고친 뒤 재실행할 때 옛 결과가 현재 데이터로
|
|
180
|
+
* 둔갑한다(pending=0). 지문이 다르면 다른 조합으로 취급돼 재발사된다.
|
|
181
|
+
*/
|
|
182
|
+
fixtureHash?: string;
|
|
183
|
+
model: string;
|
|
184
|
+
mode: BenchMode;
|
|
185
|
+
iteration: number;
|
|
186
|
+
/** HTTP/전송 오류 포함 전체 요청이 분모다 — 오류를 분모에서 빼면 오류율 차이가 은폐된다. */
|
|
187
|
+
transportError?: string;
|
|
188
|
+
classification?: BenchClassification;
|
|
189
|
+
/** 스트림에서 델타 연결과 done.text 가 달랐는지 (펜스 transform 불변식의 실전 검증) */
|
|
190
|
+
deltaDoneMismatch?: boolean;
|
|
191
|
+
durationMs: number;
|
|
192
|
+
outputTokens?: number;
|
|
193
|
+
costUsd?: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** fixture 내용 지문 — djb2 (의존성 없이 충분: 충돌해 봐야 옛 레코드 하나가 스킵될 뿐). */
|
|
197
|
+
export function fingerprintFixture(content: {
|
|
198
|
+
prompt: string;
|
|
199
|
+
jsonSchema?: string;
|
|
200
|
+
tools?: unknown;
|
|
201
|
+
}): string {
|
|
202
|
+
const text = `${content.prompt}${content.jsonSchema ?? ""}${JSON.stringify(content.tools ?? null)}`;
|
|
203
|
+
let hash = 5381;
|
|
204
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
205
|
+
hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0;
|
|
206
|
+
}
|
|
207
|
+
return hash.toString(16).padStart(8, "0");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function checkpointKey(
|
|
211
|
+
record: Pick<BenchRecord, "fixture" | "fixtureHash" | "model" | "mode" | "iteration">,
|
|
212
|
+
): string {
|
|
213
|
+
return `${record.model}|${record.fixture}|${record.fixtureHash ?? "-"}|${record.mode}|${record.iteration}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** JSONL checkpoint 파일 파싱. 깨진 줄(중단 시 부분 기록)은 건너뛴다. */
|
|
217
|
+
export function parseCheckpointLines(jsonl: string): Map<string, BenchRecord> {
|
|
218
|
+
const map = new Map<string, BenchRecord>();
|
|
219
|
+
for (const line of jsonl.split("\n")) {
|
|
220
|
+
if (!line.trim()) continue;
|
|
221
|
+
try {
|
|
222
|
+
const record = JSON.parse(line) as BenchRecord;
|
|
223
|
+
map.set(checkpointKey(record), record);
|
|
224
|
+
} catch {
|
|
225
|
+
// 부분 기록된 마지막 줄 — resume 시 해당 조합만 재발사된다
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return map;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── 집계 ──
|
|
232
|
+
|
|
233
|
+
export interface BenchAggregateRow {
|
|
234
|
+
model: string;
|
|
235
|
+
fixture: string;
|
|
236
|
+
mode: BenchMode;
|
|
237
|
+
total: number;
|
|
238
|
+
transportErrors: number;
|
|
239
|
+
syntaxFails: number;
|
|
240
|
+
contractFails: number;
|
|
241
|
+
wrapperKey: number;
|
|
242
|
+
fenceResidue: number;
|
|
243
|
+
prose: number;
|
|
244
|
+
deltaDoneMismatches: number;
|
|
245
|
+
passes: number;
|
|
246
|
+
/** 분모는 전체 요청(전송 오류 포함) */
|
|
247
|
+
passRate: number;
|
|
248
|
+
avgDurationMs: number;
|
|
249
|
+
totalOutputTokens: number;
|
|
250
|
+
totalCostUsd: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function aggregateBench(records: BenchRecord[]): BenchAggregateRow[] {
|
|
254
|
+
const groups = new Map<string, BenchRecord[]>();
|
|
255
|
+
for (const record of records) {
|
|
256
|
+
const key = `${record.model}|${record.fixture}|${record.mode}`;
|
|
257
|
+
const bucket = groups.get(key);
|
|
258
|
+
if (bucket) bucket.push(record);
|
|
259
|
+
else groups.set(key, [record]);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return [...groups.entries()]
|
|
263
|
+
.map(([key, bucket]) => {
|
|
264
|
+
const [model, fixture, mode] = key.split("|") as [string, string, BenchMode];
|
|
265
|
+
const transportErrors = bucket.filter((r) => r.transportError !== undefined).length;
|
|
266
|
+
const syntaxFails = bucket.filter((r) => r.classification?.syntax === "fail").length;
|
|
267
|
+
const contractFails = bucket.filter((r) => r.classification?.contract === "fail").length;
|
|
268
|
+
// pass = 모든 축이 깨끗한 응답 — 형태 위반(래퍼/펜스/프로즈)과 스트림 델타≠done 도
|
|
269
|
+
// 실패다. syntax/contract 만 보면 "통과인데 펜스가 남은" 응답이 성공으로 집계된다
|
|
270
|
+
// (2차 검토 #7).
|
|
271
|
+
const passes = bucket.filter(
|
|
272
|
+
(r) =>
|
|
273
|
+
r.transportError === undefined &&
|
|
274
|
+
r.classification?.syntax === "ok" &&
|
|
275
|
+
r.classification.contract === "ok" &&
|
|
276
|
+
!r.classification.topLevel.wrapperKey &&
|
|
277
|
+
!r.classification.topLevel.fenceResidue &&
|
|
278
|
+
!r.classification.topLevel.prose &&
|
|
279
|
+
r.deltaDoneMismatch !== true,
|
|
280
|
+
).length;
|
|
281
|
+
const count = (pick: (v: TopLevelViolations) => boolean) =>
|
|
282
|
+
bucket.filter((r) => r.classification !== undefined && pick(r.classification.topLevel))
|
|
283
|
+
.length;
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
model,
|
|
287
|
+
fixture,
|
|
288
|
+
mode,
|
|
289
|
+
total: bucket.length,
|
|
290
|
+
transportErrors,
|
|
291
|
+
syntaxFails,
|
|
292
|
+
contractFails,
|
|
293
|
+
wrapperKey: count((v) => v.wrapperKey),
|
|
294
|
+
fenceResidue: count((v) => v.fenceResidue),
|
|
295
|
+
prose: count((v) => v.prose),
|
|
296
|
+
deltaDoneMismatches: bucket.filter((r) => r.deltaDoneMismatch === true).length,
|
|
297
|
+
passes,
|
|
298
|
+
passRate: bucket.length === 0 ? 0 : passes / bucket.length,
|
|
299
|
+
avgDurationMs:
|
|
300
|
+
bucket.length === 0
|
|
301
|
+
? 0
|
|
302
|
+
: Math.round(bucket.reduce((sum, r) => sum + r.durationMs, 0) / bucket.length),
|
|
303
|
+
totalOutputTokens: bucket.reduce((sum, r) => sum + (r.outputTokens ?? 0), 0),
|
|
304
|
+
totalCostUsd: bucket.reduce((sum, r) => sum + (r.costUsd ?? 0), 0),
|
|
305
|
+
};
|
|
306
|
+
})
|
|
307
|
+
.toSorted((a, b) =>
|
|
308
|
+
`${a.model}${a.fixture}${a.mode}`.localeCompare(`${b.model}${b.fixture}${b.mode}`),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function formatAggregateTable(rows: BenchAggregateRow[]): string {
|
|
313
|
+
const header = [
|
|
314
|
+
"model",
|
|
315
|
+
"fixture",
|
|
316
|
+
"mode",
|
|
317
|
+
"N",
|
|
318
|
+
"pass",
|
|
319
|
+
"rate",
|
|
320
|
+
"syntax✗",
|
|
321
|
+
"contract✗",
|
|
322
|
+
"wrapper",
|
|
323
|
+
"fence",
|
|
324
|
+
"prose",
|
|
325
|
+
"transport✗",
|
|
326
|
+
"Δ≠done",
|
|
327
|
+
"avgMs",
|
|
328
|
+
"outTok",
|
|
329
|
+
"cost$",
|
|
330
|
+
];
|
|
331
|
+
const lines = rows.map((r) =>
|
|
332
|
+
[
|
|
333
|
+
r.model,
|
|
334
|
+
r.fixture,
|
|
335
|
+
r.mode,
|
|
336
|
+
r.total,
|
|
337
|
+
r.passes,
|
|
338
|
+
`${(r.passRate * 100).toFixed(1)}%`,
|
|
339
|
+
r.syntaxFails,
|
|
340
|
+
r.contractFails,
|
|
341
|
+
r.wrapperKey,
|
|
342
|
+
r.fenceResidue,
|
|
343
|
+
r.prose,
|
|
344
|
+
r.transportErrors,
|
|
345
|
+
r.deltaDoneMismatches,
|
|
346
|
+
r.avgDurationMs,
|
|
347
|
+
r.totalOutputTokens,
|
|
348
|
+
r.totalCostUsd.toFixed(4),
|
|
349
|
+
].join("\t"),
|
|
350
|
+
);
|
|
351
|
+
return [header.join("\t"), ...lines].join("\n");
|
|
352
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createFenceStripTransform, stripFences } from "./fence-strip";
|
|
4
|
+
|
|
5
|
+
// 스트림 절단 지점에 무관하게 "방출 연결 + flush == stripFences(전체)" 가 성립해야 한다.
|
|
6
|
+
function runAllSplits(full: string): void {
|
|
7
|
+
const expected = stripFences(full);
|
|
8
|
+
for (let i = 0; i <= full.length; i += 1) {
|
|
9
|
+
for (let j = i; j <= full.length; j += 1) {
|
|
10
|
+
const t = createFenceStripTransform();
|
|
11
|
+
const out =
|
|
12
|
+
t.push(full.slice(0, i)) + t.push(full.slice(i, j)) + t.push(full.slice(j)) + t.flush();
|
|
13
|
+
expect(out, `split at ${i},${j}`).toBe(expected);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("stripFences", () => {
|
|
19
|
+
it("펜스 없음 — 양끝 공백만 제거하고 원문 유지 (1091748 케이스)", () => {
|
|
20
|
+
expect(stripFences('{"a":1}')).toBe('{"a":1}');
|
|
21
|
+
expect(stripFences(' {"a":1}\n')).toBe('{"a":1}');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("json 태그 펜스를 벗긴다", () => {
|
|
25
|
+
expect(stripFences('```json\n{"a":1}\n```')).toBe('{"a":1}');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("언어 태그 없는 펜스를 벗긴다", () => {
|
|
29
|
+
expect(stripFences('```\n{"a":1}\n```')).toBe('{"a":1}');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("선두 공백/개행 + 펜스 조합", () => {
|
|
33
|
+
expect(stripFences('\n\n ```json\n{"a":1}\n``` \n')).toBe('{"a":1}');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("JSON 문자열 값 내부의 백틱은 건드리지 않는다", () => {
|
|
37
|
+
const inner = '{"code":"```json\\n{}\\n```ok"}';
|
|
38
|
+
expect(stripFences(inner)).toBe(inner);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("펜스만 있고 본문 없음 → 빈 문자열", () => {
|
|
42
|
+
expect(stripFences("```json\n```")).toBe("");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("createFenceStripTransform", () => {
|
|
47
|
+
it("여는 펜스가 delta 경계에 걸쳐 도착해도 본문만 방출한다", () => {
|
|
48
|
+
const t = createFenceStripTransform();
|
|
49
|
+
let out = t.push("``");
|
|
50
|
+
out += t.push("`js");
|
|
51
|
+
out += t.push('on\n{"a"');
|
|
52
|
+
out += t.push(":1}\n``");
|
|
53
|
+
out += t.push("`");
|
|
54
|
+
out += t.flush();
|
|
55
|
+
expect(out).toBe('{"a":1}');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("닫는 펜스 직전까지 방출되고 tail 홀드백이 펜스를 삼킨다", () => {
|
|
59
|
+
const t = createFenceStripTransform();
|
|
60
|
+
const first = t.push('```json\n{"a":1}');
|
|
61
|
+
// 본문은 도착 즉시 흘러야 한다 (닫는 펜스 후보가 아니므로)
|
|
62
|
+
expect(first).toBe('{"a":1}');
|
|
63
|
+
expect(t.push("\n```") + t.flush()).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("홀드백 후보였으나 펜스가 아니면 내용 무손실로 방출한다", () => {
|
|
67
|
+
const t = createFenceStripTransform();
|
|
68
|
+
const out = t.push('{"a":"x') + t.push("``") + t.push('y"}') + t.flush();
|
|
69
|
+
expect(out).toBe('{"a":"x``y"}');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("닫는 펜스 앞의 긴 공백 런도 전부 보류 후 폐기한다", () => {
|
|
73
|
+
const gap = "\n".repeat(4_000) + " ".repeat(4_000);
|
|
74
|
+
const t = createFenceStripTransform();
|
|
75
|
+
const out = t.push('```json\n{"a":1}') + t.push(gap) + t.push("\n```") + t.flush();
|
|
76
|
+
// 전체 연산과 동일해야 한다: trim 이 trailing 공백을 지우고 펜스가 벗겨진다
|
|
77
|
+
expect(out).toBe(stripFences('```json\n{"a":1}' + gap + "\n```"));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("미완성 펜스로 끝나는 스트림은 그 백틱을 방출한다 (닫는 펜스 아님)", () => {
|
|
81
|
+
const t = createFenceStripTransform();
|
|
82
|
+
const out = t.push('{"a":1}\n``') + t.flush();
|
|
83
|
+
expect(out).toBe('{"a":1}\n``');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("본문 뒤 공백만으로 끝나면 trailing 공백 제거만 적용된다", () => {
|
|
87
|
+
const t = createFenceStripTransform();
|
|
88
|
+
const out = t.push('{"a":1}') + t.push(" \n\n") + t.flush();
|
|
89
|
+
expect(out).toBe('{"a":1}');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("불변식: 임의 절단 지점에서 방출 연결 == stripFences(전체)", () => {
|
|
93
|
+
const samples = [
|
|
94
|
+
'{"a":1}',
|
|
95
|
+
'```json\n{"a":1}\n```',
|
|
96
|
+
'```JSON\n{"a":1}\n```',
|
|
97
|
+
'```\n{"a":1}\n```',
|
|
98
|
+
' \n```json \n\n{"a":[1,2]}\n\n``` ',
|
|
99
|
+
'{"code":"```"}',
|
|
100
|
+
"```json\n```",
|
|
101
|
+
"``",
|
|
102
|
+
"```jsx\n{}\n```",
|
|
103
|
+
'{"a":1}\n``` extra',
|
|
104
|
+
'{"a":1}``````',
|
|
105
|
+
" \n ",
|
|
106
|
+
"",
|
|
107
|
+
'```json{"inline":true}```',
|
|
108
|
+
];
|
|
109
|
+
for (const sample of samples) runAllSplits(sample);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("불변식: 유니코드 공백(NBSP·VT·FF·LS·PS·BOM)에서도 성립 (2차 검토 #8)", () => {
|
|
113
|
+
// trim() 이 지우는 비 ASCII 공백이 tail 에 있으면 홀드백이 함께 보류해야 한다
|
|
114
|
+
const ws = [" ", "\v", "\f", "
", "
", ""];
|
|
115
|
+
for (const w of ws) {
|
|
116
|
+
runAllSplits(`{"a":1}${w}`);
|
|
117
|
+
runAllSplits(`${w}{"a":1}${w}${w}`);
|
|
118
|
+
runAllSplits(`\`\`\`json\n{"a":1}\n\`\`\`${w}`);
|
|
119
|
+
runAllSplits(`{"a":1}${w}\n\`\`\``);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 코드펜스 스트리핑 (SON-532).
|
|
3
|
+
*
|
|
4
|
+
* anthropic 은 스키마/envelope 계약을 프롬프트로 안내하므로 응답은 평범한 텍스트다.
|
|
5
|
+
* sonnet 계열이 지시에도 불구하고 ```json 펜스를 붙이는 실측이 있어, 서버가 펜스와
|
|
6
|
+
* 양끝 공백만 벗겨 소비자(zod)/parseEnvelope/EnvelopeStreamParser 에 순수 JSON 을
|
|
7
|
+
* 넘긴다. 머리말·후미 프로즈 구제는 하지 않는다 — 관용 복구는 2026-07 medpath 13.5k
|
|
8
|
+
* 오염 사고로 기각된 원칙이며, 프로즈 응답은 다음 층에서 정직하게 실패한다.
|
|
9
|
+
*
|
|
10
|
+
* `stripFences` 는 stream-json-adapter 의 stripCodeFence(비스트림 result 경로)에서
|
|
11
|
+
* 추출한 동일 시맨틱이다 — 스트림 델타 연결과 비스트림 최종 텍스트가 같은 결과여야
|
|
12
|
+
* 클라이언트가 어느 쪽을 조립해도 어긋나지 않는다.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function stripFences(s: string): string {
|
|
16
|
+
return s
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
19
|
+
.replace(/\n?```\s*$/i, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FenceStripTransform {
|
|
23
|
+
/** delta 를 넣고 지금 방출해도 안전한 조각을 돌려받는다. */
|
|
24
|
+
push(delta: string): string;
|
|
25
|
+
/** 스트림 종료. 보류분 중 펜스·trailing 공백이 아닌 잔여를 돌려준다. */
|
|
26
|
+
flush(): string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 델타 스트림용 통과 필터. 불변식: 임의 절단 지점에 대해
|
|
31
|
+
* `push(...) 들의 연결 + flush() == stripFences(전체 텍스트)`.
|
|
32
|
+
*
|
|
33
|
+
* tail 홀드백은 고정 크기가 아니라 계약 기반이다 — "공백·개행·백틱만으로 이루어진
|
|
34
|
+
* suffix"(닫는 펜스 후보)를 스트림 완료까지 보류하고, 후보를 깨는 내용이 오면 즉시
|
|
35
|
+
* 방출을 재개한다. 닫는 펜스 앞 공백은 임의 길이일 수 있어 고정 크기로는 "양끝 공백
|
|
36
|
+
* 제거" 불변식이 성립하지 않는다.
|
|
37
|
+
*/
|
|
38
|
+
export function createFenceStripTransform(): FenceStripTransform {
|
|
39
|
+
let full = "";
|
|
40
|
+
// full 에서 본문(leading trim + 여는 펜스 이후)이 시작하는 인덱스. -1 = 아직 미확정.
|
|
41
|
+
let bodyStart = -1;
|
|
42
|
+
// 본문 기준으로 이미 방출한 문자 수. 방출분은 최종 결과의 prefix 임이 보장된다 —
|
|
43
|
+
// 공백은 비공백 내용이 뒤따르기 전까지 항상 보류되므로, 나중에 닫는 펜스의 일부로
|
|
44
|
+
// 판명될 문자가 미리 방출되는 일이 없다.
|
|
45
|
+
let emitted = 0;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
push(delta: string): string {
|
|
49
|
+
full += delta;
|
|
50
|
+
if (bodyStart < 0) {
|
|
51
|
+
bodyStart = resolveLeadingOffset(full);
|
|
52
|
+
if (bodyStart < 0) return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const body = full.slice(bodyStart);
|
|
56
|
+
const unemitted = body.slice(emitted);
|
|
57
|
+
const safe = unemitted.length - trailingHoldLength(unemitted);
|
|
58
|
+
if (safe <= 0) return "";
|
|
59
|
+
|
|
60
|
+
const out = unemitted.slice(0, safe);
|
|
61
|
+
emitted += safe;
|
|
62
|
+
return out;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
flush(): string {
|
|
66
|
+
// 방출분이 최종 결과의 prefix 라는 불변식 위에서, 잔여만 잘라 돌려준다.
|
|
67
|
+
return stripFences(full).slice(emitted);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 여는 펜스 판정. 아직 "```json" 으로 자랄 수 있는 경계(백틱 1~2개, "```j(s(o))",
|
|
74
|
+
* 펜스 직후 공백만 도착)면 -1 로 보류한다. stripFences 의 leading 정규식과 동일한
|
|
75
|
+
* 소비 규칙: ```(json)? 뒤에 이어지는 공백 전부가 펜스에 붙어 떨어져 나간다.
|
|
76
|
+
*/
|
|
77
|
+
function resolveLeadingOffset(text: string): number {
|
|
78
|
+
const trimmedStart = text.trimStart();
|
|
79
|
+
if (trimmedStart === "") return -1; // 아직 공백뿐 — flush 가 정리한다
|
|
80
|
+
const ws = text.length - trimmedStart.length;
|
|
81
|
+
|
|
82
|
+
if (!trimmedStart.startsWith("`")) return ws;
|
|
83
|
+
if (/^`{1,2}$/.test(trimmedStart)) return -1; // "```" 로 자랄 수 있음
|
|
84
|
+
|
|
85
|
+
const fence = /^```(?:json)?/i.exec(trimmedStart);
|
|
86
|
+
if (!fence) return ws; // "`x", "``x" — 펜스가 아니라 본문
|
|
87
|
+
if (/^```(?:j|js|jso)$/i.test(trimmedStart)) return -1; // "json" 태그가 자라는 중
|
|
88
|
+
|
|
89
|
+
const afterFence = trimmedStart.slice(fence[0].length);
|
|
90
|
+
const afterFenceBody = afterFence.trimStart();
|
|
91
|
+
if (afterFenceBody === "") return -1; // 펜스 뒤 공백만 — 본문 첫 글자 대기
|
|
92
|
+
return ws + fence[0].length + (afterFence.length - afterFenceBody.length);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 아직 방출하면 안 되는 suffix 길이. 닫는 펜스 후보는 "(개행?)(백틱 1~3)(공백*)" 또는
|
|
97
|
+
* 순수 trailing 공백 — stripFences 의 trim + /\n?```\s*$/ 가 지울 수 있는 모든 형태를
|
|
98
|
+
* 덮는다. 공백 클래스는 반드시 `\s`(+BOM) — trim() 은 NBSP·VT·FF·U+2028·U+2029 까지
|
|
99
|
+
* 지우므로 ASCII 만 보류하면 그 문자들이 먼저 방출돼 "방출 == strip(전체)" 불변식이
|
|
100
|
+
* 깨진다(2차 검토 #8 실측). 미방출 구간에만 적용하므로 비용은 (보류분+delta)에 비례.
|
|
101
|
+
*/
|
|
102
|
+
function trailingHoldLength(unemitted: string): number {
|
|
103
|
+
const match = /(?:\n?`{1,3})?[\s]*$/.exec(unemitted);
|
|
104
|
+
return match ? match[0].length : 0;
|
|
105
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type ModelFallback,
|
|
15
15
|
type ProviderTokenUsageBreakdown,
|
|
16
16
|
} from "../common/provider-dispatcher";
|
|
17
|
+
import { stripFences } from "./fence-strip";
|
|
17
18
|
|
|
18
19
|
// ── 입력 어댑터 ────────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -195,13 +196,6 @@ function toUsageBreakdown(u: ClaudeUsage): ProviderTokenUsageBreakdown {
|
|
|
195
196
|
};
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
function stripCodeFence(s: string): string {
|
|
199
|
-
return s
|
|
200
|
-
.trim()
|
|
201
|
-
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
202
|
-
.replace(/\n?```\s*$/i, "");
|
|
203
|
-
}
|
|
204
|
-
|
|
205
199
|
function structuredOutputToolUseText(j: ResponseItem): string | undefined {
|
|
206
200
|
const message = asObject(j.message);
|
|
207
201
|
const content = message?.content;
|
|
@@ -424,7 +418,7 @@ export function handleStreamJsonLine(
|
|
|
424
418
|
} else if (j.structured_output !== undefined) {
|
|
425
419
|
text = JSON.stringify(j.structured_output);
|
|
426
420
|
} else {
|
|
427
|
-
text =
|
|
421
|
+
text = stripFences(typeof j.result === "string" ? j.result : "");
|
|
428
422
|
}
|
|
429
423
|
|
|
430
424
|
const rawUsage = asObject(j.usage) ?? {};
|