@introspection-ai/recipe-documents-docx 0.0.0-stage → 0.1.0
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/README.md +14 -2
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/dist/python.d.ts +26 -0
- package/dist/python.d.ts.map +1 -0
- package/dist/python.js +52 -0
- package/dist/python.js.map +1 -0
- package/package.json +47 -3
- package/python/docx_helper.py +55 -0
- package/python/pyproject.toml +10 -0
- package/python/recipe_documents_docx/LICENSE-redline-bench +28 -0
- package/python/recipe_documents_docx/__init__.py +1 -0
- package/python/recipe_documents_docx/anchors.py +186 -0
- package/python/recipe_documents_docx/model.py +279 -0
- package/python/recipe_documents_docx/ops.py +939 -0
- package/python/uv.lock +119 -0
package/README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @introspection-ai/recipe-documents-docx
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Word (`.docx`) format for the Recipes [`documents` tool](../../../docs/documents.md):
|
|
4
|
+
`read`, `propose_edits`, `comment`, `respond` and `remove_section`, on paper-docx.
|
|
5
|
+
`render` is not registered.
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{ "pi": { "documents": [{ "format": "docx" }] } }
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The helpers in `python/` run with the Recipe's `pi.runtime.python` environment, which must
|
|
12
|
+
provide `paper-docx==0.2.0`. `python/uv.lock` pins what the tests run on.
|
|
13
|
+
|
|
14
|
+
Licences: the anchoring and section removal follow RedlineBench's redline engine (MIT,
|
|
15
|
+
Crosby Legal), see `python/recipe_documents_docx/LICENSE-redline-bench`. The rest is Apache-2.0.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type DocumentAdapter, type DocumentAdapterContext, type DocumentCapabilities, type DocumentCommentInput, type DocumentProposeEditsInput, type DocumentReadInput, type DocumentRemoveSectionInput, type DocumentRespondInput, type DocumentReviseInput, type DocumentResult } from "@introspection-ai/recipes/documents";
|
|
2
|
+
export { pythonExecutable, runHelper } from "./python.js";
|
|
3
|
+
/** The helper entry point shipped in this package. */
|
|
4
|
+
export declare const DOCX_HELPER: string;
|
|
5
|
+
/**
|
|
6
|
+
* What Word supports. `render` is false: page images need a layout engine
|
|
7
|
+
* (LibreOffice or Word), which the runtime image does not carry, so the
|
|
8
|
+
* command is absent for .docx rather than failing.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DOCX_DOCUMENT_CAPABILITIES: DocumentCapabilities;
|
|
11
|
+
export declare class DocxDocumentAdapter implements DocumentAdapter {
|
|
12
|
+
private readonly env;
|
|
13
|
+
private readonly script;
|
|
14
|
+
readonly format = "docx";
|
|
15
|
+
readonly extensions: readonly [".docx"];
|
|
16
|
+
readonly capabilities: DocumentCapabilities;
|
|
17
|
+
constructor(env?: NodeJS.ProcessEnv, script?: string);
|
|
18
|
+
private call;
|
|
19
|
+
read(ctx: DocumentAdapterContext, input: DocumentReadInput): Promise<DocumentResult>;
|
|
20
|
+
revise(ctx: DocumentAdapterContext, input: DocumentReviseInput): Promise<DocumentResult>;
|
|
21
|
+
proposeEdits(ctx: DocumentAdapterContext, input: DocumentProposeEditsInput): Promise<DocumentResult>;
|
|
22
|
+
comment(ctx: DocumentAdapterContext, input: DocumentCommentInput): Promise<DocumentResult>;
|
|
23
|
+
respond(ctx: DocumentAdapterContext, input: DocumentRespondInput): Promise<DocumentResult>;
|
|
24
|
+
removeSection(ctx: DocumentAdapterContext, input: DocumentRemoveSectionInput): Promise<DocumentResult>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Word as a document format.
|
|
28
|
+
*
|
|
29
|
+
* ```json
|
|
30
|
+
* { "pi": { "documents": [{ "format": "docx" }] } }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* ```yaml
|
|
34
|
+
* tools: [documents]
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export declare const docxDocumentFormatModule: import("@introspection-ai/recipes/documents").RecipeDocumentFormatModule;
|
|
38
|
+
export default docxDocumentFormatModule;
|
|
39
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,yBAAyB,EAC9B,KAAK,iBAAiB,EACtB,KAAK,0BAA0B,EAC/B,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACpB,MAAM,qCAAqC,CAAC;AAI7C,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE1D,sDAAsD;AACtD,eAAO,MAAM,WAAW,QAAsE,CAAC;AAE/F;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,EAAE,oBAQxC,CAAC;AAEF,qBAAa,mBAAoB,YAAW,eAAe;IAK7C,OAAO,CAAC,QAAQ,CAAC,GAAG;IAAmC,OAAO,CAAC,QAAQ,CAAC,MAAM;IAJ1F,QAAQ,CAAC,MAAM,UAAU;IACzB,QAAQ,CAAC,UAAU,YAAI,OAAO,EAAW;IACzC,QAAQ,CAAC,YAAY,uBAA8B;IAEnD,YAA6B,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAmB,MAAM,SAAc,EAAI;IAE5G,OAAO,CAAC,IAAI;IAIZ,IAAI,CAAC,GAAG,EAAE,sBAAsB,EAAE,KAAK,EAAE,iBAAiB,2BAEzD;IAED,MAAM,CAAC,GAAG,EAAE,sBAAsB,EAAE,KAAK,EAAE,mBAAmB,2BAE7D;IAED,YAAY,CAAC,GAAG,EAAE,sBAAsB,EAAE,KAAK,EAAE,yBAAyB,2BAEzE;IAED,OAAO,CAAC,GAAG,EAAE,sBAAsB,EAAE,KAAK,EAAE,oBAAoB,2BAE/D;IAED,OAAO,CAAC,GAAG,EAAE,sBAAsB,EAAE,KAAK,EAAE,oBAAoB,2BAE/D;IAED,aAAa,CAAC,GAAG,EAAE,sBAAsB,EAAE,KAAK,EAAE,0BAA0B,2BAE3E;CACF;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,0EAKnC,CAAC;eAEY,wBAAwB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { createDocumentFormatModule, } from "@introspection-ai/recipes/documents";
|
|
3
|
+
import { runHelper } from "./python.js";
|
|
4
|
+
export { pythonExecutable, runHelper } from "./python.js";
|
|
5
|
+
/** The helper entry point shipped in this package. */
|
|
6
|
+
export const DOCX_HELPER = fileURLToPath(new URL("../python/docx_helper.py", import.meta.url));
|
|
7
|
+
/**
|
|
8
|
+
* What Word supports. `render` is false: page images need a layout engine
|
|
9
|
+
* (LibreOffice or Word), which the runtime image does not carry, so the
|
|
10
|
+
* command is absent for .docx rather than failing.
|
|
11
|
+
*/
|
|
12
|
+
export const DOCX_DOCUMENT_CAPABILITIES = {
|
|
13
|
+
read: true,
|
|
14
|
+
revise: true,
|
|
15
|
+
proposeEdits: true,
|
|
16
|
+
comment: true,
|
|
17
|
+
respond: true,
|
|
18
|
+
removeSection: true,
|
|
19
|
+
render: false,
|
|
20
|
+
};
|
|
21
|
+
export class DocxDocumentAdapter {
|
|
22
|
+
env;
|
|
23
|
+
script;
|
|
24
|
+
format = "docx";
|
|
25
|
+
extensions = [".docx"];
|
|
26
|
+
capabilities = DOCX_DOCUMENT_CAPABILITIES;
|
|
27
|
+
constructor(env = process.env, script = DOCX_HELPER) {
|
|
28
|
+
this.env = env;
|
|
29
|
+
this.script = script;
|
|
30
|
+
}
|
|
31
|
+
call(ctx, command, input) {
|
|
32
|
+
return runHelper(this.script, { command, ...input }, { env: this.env, cwd: ctx.cwd, signal: ctx.signal });
|
|
33
|
+
}
|
|
34
|
+
read(ctx, input) {
|
|
35
|
+
return this.call(ctx, "read", input);
|
|
36
|
+
}
|
|
37
|
+
revise(ctx, input) {
|
|
38
|
+
return this.call(ctx, "revise", input);
|
|
39
|
+
}
|
|
40
|
+
proposeEdits(ctx, input) {
|
|
41
|
+
return this.call(ctx, "propose_edits", input);
|
|
42
|
+
}
|
|
43
|
+
comment(ctx, input) {
|
|
44
|
+
return this.call(ctx, "comment", input);
|
|
45
|
+
}
|
|
46
|
+
respond(ctx, input) {
|
|
47
|
+
return this.call(ctx, "respond", input);
|
|
48
|
+
}
|
|
49
|
+
removeSection(ctx, input) {
|
|
50
|
+
return this.call(ctx, "remove_section", input);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Word as a document format.
|
|
55
|
+
*
|
|
56
|
+
* ```json
|
|
57
|
+
* { "pi": { "documents": [{ "format": "docx" }] } }
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* ```yaml
|
|
61
|
+
* tools: [documents]
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export const docxDocumentFormatModule = createDocumentFormatModule({
|
|
65
|
+
format: "docx",
|
|
66
|
+
extensions: [".docx"],
|
|
67
|
+
capabilities: DOCX_DOCUMENT_CAPABILITIES,
|
|
68
|
+
createAdapter: ({ env }) => new DocxDocumentAdapter(env),
|
|
69
|
+
});
|
|
70
|
+
export default docxDocumentFormatModule;
|
|
71
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EACL,0BAA0B,GAW3B,MAAM,qCAAqC,CAAC;AAE7C,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE1D,sDAAsD;AACtD,MAAM,CAAC,MAAM,WAAW,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,0BAA0B,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE/F;;;;GAIG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAyB;IAC9D,IAAI,EAAE,IAAI;IACV,MAAM,EAAE,IAAI;IACZ,YAAY,EAAE,IAAI;IAClB,OAAO,EAAE,IAAI;IACb,OAAO,EAAE,IAAI;IACb,aAAa,EAAE,IAAI;IACnB,MAAM,EAAE,KAAK;CACd,CAAC;AAEF,MAAM,OAAO,mBAAmB;IAKD,GAAG;IAAoD,MAAM;IAJjF,MAAM,GAAG,MAAM,CAAC;IAChB,UAAU,GAAG,CAAC,OAAO,CAAU,CAAC;IAChC,YAAY,GAAG,0BAA0B,CAAC;IAEnD,YAA6B,GAAG,GAAsB,OAAO,CAAC,GAAG,EAAmB,MAAM,GAAG,WAAW;mBAA3E,GAAG;sBAAoD,MAAM;IAAiB,CAAC;IAEpG,IAAI,CAAC,GAA2B,EAAE,OAAe,EAAE,KAAa;QACtE,OAAO,SAAS,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,GAAG,KAAK,EAAE,EAAE,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;IAC5G,CAAC;IAED,IAAI,CAAC,GAA2B,EAAE,KAAwB;QACxD,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,CAAC,GAA2B,EAAE,KAA0B;QAC5D,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC;IACzC,CAAC;IAED,YAAY,CAAC,GAA2B,EAAE,KAAgC;QACxE,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,KAAK,CAAC,CAAC;IAChD,CAAC;IAED,OAAO,CAAC,GAA2B,EAAE,KAA2B;QAC9D,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;IAC1C,CAAC;IAED,OAAO,CAAC,GAA2B,EAAE,KAA2B;QAC9D,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;IAC1C,CAAC;IAED,aAAa,CAAC,GAA2B,EAAE,KAAiC;QAC1E,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,gBAAgB,EAAE,KAAK,CAAC,CAAC;IACjD,CAAC;CACF;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,0BAA0B,CAAC;IACjE,MAAM,EAAE,MAAM;IACd,UAAU,EAAE,CAAC,OAAO,CAAC;IACrB,YAAY,EAAE,0BAA0B;IACxC,aAAa,EAAE,CAAC,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC,IAAI,mBAAmB,CAAC,GAAG,CAAC;CACzD,CAAC,CAAC;AAEH,eAAe,wBAAwB,CAAC"}
|
package/dist/python.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the helpers run: the Recipe's own Python environment.
|
|
3
|
+
*
|
|
4
|
+
* The runtime activates `pi.runtime.python` as VIRTUAL_ENV, and that
|
|
5
|
+
* environment must provide paper-docx. A package-level Python environment
|
|
6
|
+
* is a follow-up; until then the Recipe declares the dependency.
|
|
7
|
+
* `RECIPE_DOCUMENTS_PYTHON` overrides the interpreter (tests, custom hosts).
|
|
8
|
+
*/
|
|
9
|
+
export declare function pythonExecutable(env?: NodeJS.ProcessEnv): string;
|
|
10
|
+
export interface HelperResult {
|
|
11
|
+
text: string;
|
|
12
|
+
details?: Record<string, unknown>;
|
|
13
|
+
images?: Array<{
|
|
14
|
+
label: string;
|
|
15
|
+
data: string;
|
|
16
|
+
mimeType: string;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
/** Run one helper request: JSON on stdin, JSON on stdout. A helper error becomes the tool call's error. */
|
|
20
|
+
export declare function runHelper(script: string, request: Record<string, unknown>, options: {
|
|
21
|
+
env: NodeJS.ProcessEnv;
|
|
22
|
+
cwd: string;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}): Promise<HelperResult>;
|
|
26
|
+
//# sourceMappingURL=python.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python.d.ts","sourceRoot":"","sources":["../src/python.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAI7E;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACnE;AAED,2GAA2G;AAC3G,wBAAgB,SAAS,CACvB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,OAAO,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GACzF,OAAO,CAAC,YAAY,CAAC,CAmCvB"}
|
package/dist/python.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* How the helpers run: the Recipe's own Python environment.
|
|
5
|
+
*
|
|
6
|
+
* The runtime activates `pi.runtime.python` as VIRTUAL_ENV, and that
|
|
7
|
+
* environment must provide paper-docx. A package-level Python environment
|
|
8
|
+
* is a follow-up; until then the Recipe declares the dependency.
|
|
9
|
+
* `RECIPE_DOCUMENTS_PYTHON` overrides the interpreter (tests, custom hosts).
|
|
10
|
+
*/
|
|
11
|
+
export function pythonExecutable(env = process.env) {
|
|
12
|
+
if (env.RECIPE_DOCUMENTS_PYTHON)
|
|
13
|
+
return env.RECIPE_DOCUMENTS_PYTHON;
|
|
14
|
+
if (env.VIRTUAL_ENV)
|
|
15
|
+
return resolve(env.VIRTUAL_ENV, "bin", "python");
|
|
16
|
+
return "python3";
|
|
17
|
+
}
|
|
18
|
+
/** Run one helper request: JSON on stdin, JSON on stdout. A helper error becomes the tool call's error. */
|
|
19
|
+
export function runHelper(script, request, options) {
|
|
20
|
+
const started = performance.now();
|
|
21
|
+
return new Promise((resolvePromise, reject) => {
|
|
22
|
+
const child = execFile(pythonExecutable(options.env), ["-I", script], {
|
|
23
|
+
cwd: options.cwd,
|
|
24
|
+
env: { ...options.env, PYTHONDONTWRITEBYTECODE: "1", PYTHONIOENCODING: "utf-8" },
|
|
25
|
+
timeout: options.timeoutMs ?? 300_000,
|
|
26
|
+
maxBuffer: 64 << 20,
|
|
27
|
+
signal: options.signal,
|
|
28
|
+
}, (error, stdout, stderr) => {
|
|
29
|
+
let output;
|
|
30
|
+
try {
|
|
31
|
+
output = JSON.parse(String(stdout || "null"));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
output = undefined;
|
|
35
|
+
}
|
|
36
|
+
if (output && typeof output.error === "string")
|
|
37
|
+
return reject(new Error(output.error));
|
|
38
|
+
if (!output || typeof output.text !== "string") {
|
|
39
|
+
const detail = String(stderr || error?.message || "no output").slice(-600);
|
|
40
|
+
return reject(new Error(`document helper failed: ${detail}`));
|
|
41
|
+
}
|
|
42
|
+
const timing = (output.details?.timing ?? {});
|
|
43
|
+
output.details = {
|
|
44
|
+
...output.details,
|
|
45
|
+
timing: { ...timing, call_ms: Math.round(performance.now() - started) },
|
|
46
|
+
};
|
|
47
|
+
resolvePromise(output);
|
|
48
|
+
});
|
|
49
|
+
child.stdin?.end(JSON.stringify(request));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=python.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python.js","sourceRoot":"","sources":["../src/python.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAG,GAAsB,OAAO,CAAC,GAAG;IACnE,IAAI,GAAG,CAAC,uBAAuB;QAAE,OAAO,GAAG,CAAC,uBAAuB,CAAC;IACpE,IAAI,GAAG,CAAC,WAAW;QAAE,OAAO,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;IACtE,OAAO,SAAS,CAAC;AACnB,CAAC;AAQD,2GAA2G;AAC3G,MAAM,UAAU,SAAS,CACvB,MAAc,EACd,OAAgC,EAChC,OAA0F;IAE1F,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;IAClC,OAAO,IAAI,OAAO,CAAC,CAAC,cAAc,EAAE,MAAM,EAAE,EAAE;QAC5C,MAAM,KAAK,GAAG,QAAQ,CACpB,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,EAC7B,CAAC,IAAI,EAAE,MAAM,CAAC,EACd;YACE,GAAG,EAAE,OAAO,CAAC,GAAG;YAChB,GAAG,EAAE,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,uBAAuB,EAAE,GAAG,EAAE,gBAAgB,EAAE,OAAO,EAAE;YAChF,OAAO,EAAE,OAAO,CAAC,SAAS,IAAI,OAAO;YACrC,SAAS,EAAE,EAAE,IAAI,EAAE;YACnB,MAAM,EAAE,OAAO,CAAC,MAAM;SACvB,EACD,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE;YACxB,IAAI,MAAuD,CAAC;YAC5D,IAAI,CAAC;gBACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,CAAC,CAAC;YAChD,CAAC;YAAC,MAAM,CAAC;gBACP,MAAM,GAAG,SAAS,CAAC;YACrB,CAAC;YACD,IAAI,MAAM,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ;gBAAE,OAAO,MAAM,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;YACvF,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAC/C,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,IAAI,KAAK,EAAE,OAAO,IAAI,WAAW,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC;gBAC3E,OAAO,MAAM,CAAC,IAAI,KAAK,CAAC,2BAA2B,MAAM,EAAE,CAAC,CAAC,CAAC;YAChE,CAAC;YACD,MAAM,MAAM,GAAG,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,CAA2B,CAAC;YACxE,MAAM,CAAC,OAAO,GAAG;gBACf,GAAG,MAAM,CAAC,OAAO;gBACjB,MAAM,EAAE,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC,EAAE;aACxE,CAAC;YACF,cAAc,CAAC,MAAM,CAAC,CAAC;QACzB,CAAC,CACF,CAAC;QACF,KAAK,CAAC,KAAK,EAAE,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@introspection-ai/recipe-documents-docx",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Word (.docx) format for the Introspection Recipes documents tool: tracked edits, comments and responses on paper-docx.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"python/*.py",
|
|
13
|
+
"python/recipe_documents_docx/*.py",
|
|
14
|
+
"python/recipe_documents_docx/LICENSE-redline-bench",
|
|
15
|
+
"python/pyproject.toml",
|
|
16
|
+
"python/uv.lock",
|
|
17
|
+
"README.md",
|
|
18
|
+
"package.json"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+ssh://git@github.com/introspection-org/recipes.git",
|
|
23
|
+
"directory": "packages/documents/docx"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@introspection-ai/recipes": ">=0.37.0 <1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@introspection-ai/recipes": ">=0.37.0",
|
|
38
|
+
"@types/node": "^26.1.2",
|
|
39
|
+
"typescript": "^7.0.2"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"tag": "latest"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.json",
|
|
47
|
+
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
|
48
|
+
"pack:check": "pnpm pack --dry-run --json | node ../../../scripts/check-npm-pack.mjs"
|
|
49
|
+
}
|
|
6
50
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Entry point the Node adapter runs: one JSON request on stdin, one JSON result on stdout.
|
|
2
|
+
|
|
3
|
+
python -I docx_helper.py < request.json -> {"text": ..., "details": ...} or {"error": ...}
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
STARTED = time.perf_counter()
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> int:
|
|
17
|
+
try:
|
|
18
|
+
request = json.load(sys.stdin)
|
|
19
|
+
import docx
|
|
20
|
+
|
|
21
|
+
from recipe_documents_docx.ops import COMMANDS
|
|
22
|
+
|
|
23
|
+
imported = time.perf_counter()
|
|
24
|
+
opening = [0.0]
|
|
25
|
+
original_open = docx.Document
|
|
26
|
+
|
|
27
|
+
def timed_open(*args, **kwargs):
|
|
28
|
+
began = time.perf_counter()
|
|
29
|
+
try:
|
|
30
|
+
return original_open(*args, **kwargs)
|
|
31
|
+
finally:
|
|
32
|
+
opening[0] += time.perf_counter() - began
|
|
33
|
+
|
|
34
|
+
docx.Document = timed_open
|
|
35
|
+
command = COMMANDS.get(request.get("command"))
|
|
36
|
+
if command is None:
|
|
37
|
+
raise ValueError(f"the Word adapter has no command {request.get('command')!r}")
|
|
38
|
+
result = command(request)
|
|
39
|
+
result.setdefault("details", {})["timing"] = {
|
|
40
|
+
"import_ms": round((imported - STARTED) * 1000, 1),
|
|
41
|
+
"open_ms": round(opening[0] * 1000, 1),
|
|
42
|
+
"python_ms": round((time.perf_counter() - STARTED) * 1000, 1),
|
|
43
|
+
}
|
|
44
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
45
|
+
return 0
|
|
46
|
+
except ModuleNotFoundError as error:
|
|
47
|
+
print(json.dumps({"error": f"This Recipe's Python has no {error.name}: the Word adapter needs paper-docx"}))
|
|
48
|
+
return 1
|
|
49
|
+
except Exception as error: # the tool reports it as the call's error
|
|
50
|
+
print(json.dumps({"error": f"{type(error).__name__}: {error}"}))
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
sys.exit(main())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "recipe-documents-docx"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python helpers the @introspection-ai/recipe-documents-docx adapter runs. For its tests; a Recipe brings its own environment with paper-docx."
|
|
5
|
+
requires-python = ">=3.12,<3.15"
|
|
6
|
+
dependencies = ["paper-docx==0.2.0"]
|
|
7
|
+
|
|
8
|
+
[tool.uv]
|
|
9
|
+
package = false
|
|
10
|
+
constraint-dependencies = ["python-docx<0"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Crosby Legal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The benchmark dataset (the contracts, grounding documents, rubrics, and golden
|
|
26
|
+
attorney redlines hosted at https://huggingface.co/datasets/crosbylegal/RedlineBench)
|
|
27
|
+
is licensed separately under Creative Commons Attribution 4.0 International
|
|
28
|
+
(CC-BY-4.0): https://creativecommons.org/licenses/by/4.0/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Word helpers for the Recipes `documents` tool, on paper-docx."""
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Resolve a quoted anchor to one place in one paragraph, or say precisely why not.
|
|
2
|
+
|
|
3
|
+
The folding table, the paragraph-scoped search and the structured failure follow RedlineBench's
|
|
4
|
+
redline_engine/anchoring.py and references/anchors.md (https://github.com/crosbylegal/redline-bench,
|
|
5
|
+
commit b29d1113af5d98e2d577f4f17673ebb72834f107, MIT License, Copyright (c) 2026 Crosby Legal; see
|
|
6
|
+
LICENSE-redline-bench). Changes: offsets are mapped back to the raw text so the edit lands on the
|
|
7
|
+
original runs; soft hyphens and zero-width characters are dropped; a leading list number is
|
|
8
|
+
ignored when the quote only matches without it; a failure lists the closest paragraphs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from difflib import SequenceMatcher
|
|
16
|
+
|
|
17
|
+
from .model import Unit
|
|
18
|
+
|
|
19
|
+
FOLD = {
|
|
20
|
+
"‘": "'", "’": "'", "‚": "'", "‛": "'", "′": "'",
|
|
21
|
+
"“": '"', "”": '"', "„": '"', "‟": '"', "″": '"',
|
|
22
|
+
"‐": "-", "‑": "-", "‒": "-", "–": "-", "—": "-", "―": "-", "−": "-",
|
|
23
|
+
" ": " ", " ": " ", " ": " ", " ": " ", " ": " ", "\t": " ",
|
|
24
|
+
}
|
|
25
|
+
DROP = {"", "", "", "", "", ""}
|
|
26
|
+
NUMBER_PREFIX = re.compile(
|
|
27
|
+
r"^\s*(?:(?:section|article|clause|schedule|exhibit|annex)\s+)?"
|
|
28
|
+
r"(?:\(?[0-9]{1,3}[a-z]?\)?(?:\.[0-9]{1,3})*\.?|\([a-z]{1,4}\)|[a-z]{1,4}[.)]|[ivxlc]{1,6}[.)])\s+",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fold(text: str, casefold: bool = False) -> tuple[str, list[int]]:
|
|
34
|
+
"""Text normalized for matching, and for each folded character the raw index it came from."""
|
|
35
|
+
out: list[str] = []
|
|
36
|
+
index: list[int] = []
|
|
37
|
+
space = False
|
|
38
|
+
for i, ch in enumerate(text):
|
|
39
|
+
if ch in DROP:
|
|
40
|
+
continue
|
|
41
|
+
ch = FOLD.get(ch, ch)
|
|
42
|
+
if ch.isspace():
|
|
43
|
+
if space:
|
|
44
|
+
continue
|
|
45
|
+
ch, space = " ", True
|
|
46
|
+
else:
|
|
47
|
+
space = False
|
|
48
|
+
if casefold:
|
|
49
|
+
lower = ch.lower()
|
|
50
|
+
ch = lower if len(lower) == 1 else ch
|
|
51
|
+
out.append(ch)
|
|
52
|
+
index.append(i)
|
|
53
|
+
return "".join(out), index
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Located:
|
|
58
|
+
unit: Unit
|
|
59
|
+
start: int # raw offsets into unit.text
|
|
60
|
+
end: int
|
|
61
|
+
notes: list[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def text(self) -> str:
|
|
65
|
+
return self.unit.text[self.start:self.end]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AnchorFailure(Exception):
|
|
69
|
+
def __init__(self, reason: str, message: str, candidates: list[dict] | None = None):
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
self.reason = reason
|
|
72
|
+
self.candidates = candidates or []
|
|
73
|
+
|
|
74
|
+
def as_dict(self) -> dict:
|
|
75
|
+
found = {"reason": self.reason, "message": str(self)}
|
|
76
|
+
if self.candidates:
|
|
77
|
+
found["candidates"] = self.candidates
|
|
78
|
+
return found
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def occurrences(unit: Unit, needle: str, casefold: bool) -> list[tuple[int, int]]:
|
|
82
|
+
hay, index = fold(unit.text, casefold)
|
|
83
|
+
found = []
|
|
84
|
+
start = hay.find(needle)
|
|
85
|
+
while start != -1 and needle:
|
|
86
|
+
end = start + len(needle)
|
|
87
|
+
found.append((index[start], index[end - 1] + 1))
|
|
88
|
+
start = hay.find(needle, end)
|
|
89
|
+
return found
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def snippet(text: str, limit: int = 160) -> str:
|
|
93
|
+
text = " ".join(text.split())
|
|
94
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def closest(units: list[Unit], quote: str, limit: int = 3) -> list[dict]:
|
|
98
|
+
"""The paragraphs whose text is most like the quote, with the most similar stretch of each."""
|
|
99
|
+
needle, _ = fold(quote.strip(), casefold=True)
|
|
100
|
+
if not needle:
|
|
101
|
+
return []
|
|
102
|
+
scored = []
|
|
103
|
+
for unit in units:
|
|
104
|
+
hay, index = fold(unit.text, casefold=True)
|
|
105
|
+
if len(hay) < 3:
|
|
106
|
+
continue
|
|
107
|
+
matcher = SequenceMatcher(None, needle, hay, autojunk=False)
|
|
108
|
+
block = matcher.find_longest_match(0, len(needle), 0, len(hay))
|
|
109
|
+
if block.size < min(6, len(needle)):
|
|
110
|
+
continue
|
|
111
|
+
start = max(0, block.b - block.a)
|
|
112
|
+
end = min(len(hay), start + len(needle))
|
|
113
|
+
ratio = SequenceMatcher(None, needle, hay[start:end], autojunk=False).ratio()
|
|
114
|
+
if ratio >= 0.45:
|
|
115
|
+
raw = unit.text[index[start]: index[end - 1] + 1]
|
|
116
|
+
scored.append((ratio, unit.id, raw))
|
|
117
|
+
scored.sort(key=lambda item: -item[0])
|
|
118
|
+
return [{"paragraph": pid, "text": snippet(raw), "similarity": round(r, 2)} for r, pid, raw in scored[:limit]]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def locate(units: list[Unit], quote: str, paragraph: str | None = None, near: str | None = None,
|
|
122
|
+
occurrence: int | None = None) -> Located:
|
|
123
|
+
"""One place for the quote, or an AnchorFailure naming the reason and the closest candidates."""
|
|
124
|
+
if not quote or not quote.strip():
|
|
125
|
+
raise AnchorFailure("empty_quote", "quote is empty")
|
|
126
|
+
pool = units
|
|
127
|
+
if paragraph:
|
|
128
|
+
pool = [u for u in units if u.id == paragraph or u.id.startswith(f"{paragraph}.")]
|
|
129
|
+
if not pool:
|
|
130
|
+
raise AnchorFailure("paragraph_not_found", f"there is no paragraph {paragraph}: use an id from read")
|
|
131
|
+
|
|
132
|
+
attempts: list[tuple[str, bool, str | None]] = []
|
|
133
|
+
folded, _ = fold(quote.strip())
|
|
134
|
+
attempts.append((folded, False, None))
|
|
135
|
+
attempts.append((fold(quote.strip(), True)[0], True, "matched ignoring case"))
|
|
136
|
+
stripped = NUMBER_PREFIX.sub("", quote.strip(), count=1)
|
|
137
|
+
if stripped and stripped != quote.strip() and len(stripped) >= 4:
|
|
138
|
+
note = f"ignored the list number {quote.strip()[: len(quote.strip()) - len(stripped)].strip()!r}"
|
|
139
|
+
attempts.append((fold(stripped)[0], False, note))
|
|
140
|
+
attempts.append((fold(stripped, True)[0], True, note + ", matched ignoring case"))
|
|
141
|
+
|
|
142
|
+
for needle, casefold, note in attempts:
|
|
143
|
+
matches = [(u, s, e) for u in pool for s, e in occurrences(u, needle, casefold)]
|
|
144
|
+
if not matches:
|
|
145
|
+
continue
|
|
146
|
+
notes = [note] if note else []
|
|
147
|
+
if near and len(matches) > 1:
|
|
148
|
+
near_folded = fold(near.strip(), True)[0]
|
|
149
|
+
ranked = []
|
|
150
|
+
for u, s, e in matches:
|
|
151
|
+
hay, index = fold(u.text, True)
|
|
152
|
+
positions = [index[m.start()] for m in re.finditer(re.escape(near_folded), hay)] if near_folded else []
|
|
153
|
+
if positions:
|
|
154
|
+
ranked.append((min(abs(p - s) for p in positions), u, s, e))
|
|
155
|
+
if ranked:
|
|
156
|
+
ranked.sort(key=lambda item: item[0])
|
|
157
|
+
best = [item for item in ranked if item[0] == ranked[0][0]]
|
|
158
|
+
if len(best) == 1:
|
|
159
|
+
_, u, s, e = best[0]
|
|
160
|
+
return Located(u, s, e, notes)
|
|
161
|
+
matches = [(u, s, e) for _, u, s, e in best]
|
|
162
|
+
if occurrence:
|
|
163
|
+
if occurrence > len(matches):
|
|
164
|
+
raise AnchorFailure(
|
|
165
|
+
"occurrence_out_of_range",
|
|
166
|
+
f"the quote matches {len(matches)} time(s); occurrence {occurrence} doesn't exist",
|
|
167
|
+
[{"paragraph": u.id, "text": snippet(u.text[max(0, s - 40): e + 40])} for u, s, e in matches[:5]],
|
|
168
|
+
)
|
|
169
|
+
u, s, e = matches[occurrence - 1]
|
|
170
|
+
return Located(u, s, e, notes)
|
|
171
|
+
if len(matches) > 1:
|
|
172
|
+
raise AnchorFailure(
|
|
173
|
+
"ambiguous",
|
|
174
|
+
f"the quote matches {len(matches)} places: add paragraph, near or occurrence",
|
|
175
|
+
[{"paragraph": u.id, "text": snippet(u.text[max(0, s - 40): e + 40])} for u, s, e in matches[:5]],
|
|
176
|
+
)
|
|
177
|
+
u, s, e = matches[0]
|
|
178
|
+
return Located(u, s, e, notes)
|
|
179
|
+
|
|
180
|
+
where = f"paragraph {paragraph}" if paragraph else "the document"
|
|
181
|
+
raise AnchorFailure(
|
|
182
|
+
"not_found",
|
|
183
|
+
f"the quote isn't in {where} (after forgiving whitespace, quotes, dashes and case): copy it from the "
|
|
184
|
+
"current text in read; it may already include an earlier edit",
|
|
185
|
+
closest(units, quote),
|
|
186
|
+
)
|