@alisio/core 0.1.0-alpha.20 → 0.1.0-alpha.22
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/dist/agents/active.d.ts +19 -0
- package/dist/agents/active.js +41 -0
- package/dist/agents/capabilities.d.ts +26 -0
- package/dist/agents/capabilities.js +120 -0
- package/dist/agents/create-agent-skill.d.ts +11 -0
- package/dist/agents/create-agent-skill.js +60 -0
- package/dist/agents/definitions.d.ts +88 -0
- package/dist/agents/definitions.js +404 -0
- package/dist/agents/draft.d.ts +41 -0
- package/dist/agents/draft.js +133 -0
- package/dist/agents/service.d.ts +57 -0
- package/dist/agents/service.js +86 -0
- package/dist/agents/templates.d.ts +8 -0
- package/dist/agents/templates.js +102 -0
- package/dist/analysis/capabilities.d.ts +48 -0
- package/dist/analysis/capabilities.js +90 -0
- package/dist/analysis/data/csv.d.ts +46 -0
- package/dist/analysis/data/csv.js +240 -0
- package/dist/analysis/data/datasets.d.ts +132 -0
- package/dist/analysis/data/datasets.js +441 -0
- package/dist/analysis/data/describe.d.ts +27 -0
- package/dist/analysis/data/describe.js +103 -0
- package/dist/analysis/data/engine-client.d.ts +58 -0
- package/dist/analysis/data/engine-client.js +304 -0
- package/dist/analysis/data/engine-entry.d.ts +1 -0
- package/dist/analysis/data/engine-entry.js +59 -0
- package/dist/analysis/data/engine-source.d.ts +4 -0
- package/dist/analysis/data/engine-source.js +6 -0
- package/dist/analysis/data/engine-types.d.ts +86 -0
- package/dist/analysis/data/engine-types.js +7 -0
- package/dist/analysis/data/infer.d.ts +42 -0
- package/dist/analysis/data/infer.js +118 -0
- package/dist/analysis/data/ingest-worker.d.ts +17 -0
- package/dist/analysis/data/ingest-worker.js +365 -0
- package/dist/analysis/data/json.d.ts +19 -0
- package/dist/analysis/data/json.js +44 -0
- package/dist/analysis/data/query-worker.d.ts +12 -0
- package/dist/analysis/data/query-worker.js +75 -0
- package/dist/analysis/data/rows.d.ts +39 -0
- package/dist/analysis/data/rows.js +120 -0
- package/dist/analysis/data/sql-guard.d.ts +20 -0
- package/dist/analysis/data/sql-guard.js +129 -0
- package/dist/analysis/data/stats.d.ts +18 -0
- package/dist/analysis/data/stats.js +77 -0
- package/dist/analysis/install-hints.d.ts +33 -0
- package/dist/analysis/install-hints.js +144 -0
- package/dist/analysis/janitor.d.ts +60 -0
- package/dist/analysis/janitor.js +264 -0
- package/dist/analysis/jobs.d.ts +94 -0
- package/dist/analysis/jobs.js +165 -0
- package/dist/analysis/oci.d.ts +129 -0
- package/dist/analysis/oci.js +330 -0
- package/dist/analysis/python/sources.d.ts +2 -0
- package/dist/analysis/python/sources.js +10 -0
- package/dist/analysis/requirements.d.ts +32 -0
- package/dist/analysis/requirements.js +73 -0
- package/dist/analysis/rerun.d.ts +40 -0
- package/dist/analysis/rerun.js +136 -0
- package/dist/analysis/runtime-manager.d.ts +146 -0
- package/dist/analysis/runtime-manager.js +472 -0
- package/dist/analysis/status.d.ts +21 -0
- package/dist/analysis/status.js +52 -0
- package/dist/application.d.ts +48 -0
- package/dist/application.js +228 -0
- package/dist/artifacts/kinds.d.ts +35 -0
- package/dist/artifacts/kinds.js +177 -0
- package/dist/artifacts/publisher.d.ts +7 -0
- package/dist/artifacts/publisher.js +29 -0
- package/dist/artifacts/store.d.ts +119 -0
- package/dist/artifacts/store.js +581 -0
- package/dist/commands/builtins.js +13 -0
- package/dist/commands/catalog.js +13 -1
- package/dist/config.d.ts +102 -0
- package/dist/config.js +167 -6
- package/dist/core/contracts.d.ts +69 -1
- package/dist/core/runner.d.ts +34 -4
- package/dist/core/runner.js +388 -130
- package/dist/index.d.ts +30 -5
- package/dist/index.js +29 -4
- package/dist/mcp/rich.js +4 -0
- package/dist/plugins/host.js +14 -6
- package/dist/runtime/blobs.d.ts +22 -0
- package/dist/runtime/blobs.js +84 -1
- package/dist/runtime/ids.d.ts +8 -0
- package/dist/runtime/ids.js +32 -0
- package/dist/runtime/paths.d.ts +5 -0
- package/dist/runtime/paths.js +6 -0
- package/dist/runtime/process.d.ts +12 -0
- package/dist/runtime/process.js +64 -4
- package/dist/runtime/sqlite.d.ts +2 -0
- package/dist/runtime/sqlite.js +18 -0
- package/dist/runtime/store.d.ts +5 -0
- package/dist/runtime/store.js +75 -0
- package/dist/runtime/zip.d.ts +24 -0
- package/dist/runtime/zip.js +126 -0
- package/dist/sessions/children.js +4 -0
- package/dist/tools/analysis.d.ts +37 -0
- package/dist/tools/analysis.js +572 -0
- package/dist/tools/artifacts.d.ts +14 -0
- package/dist/tools/artifacts.js +143 -0
- package/dist/tools/data.d.ts +7 -0
- package/dist/tools/data.js +160 -0
- package/dist/tools/execute.js +6 -2
- package/package.json +2 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { DatasetDetailWire, DatasetFormat, DatasetRef, DatasetRowsPage, SqlDatabase } from "@alisio/sdk";
|
|
2
|
+
import type { PythonResolution } from "../runtime-manager.ts";
|
|
3
|
+
import { type SampleRows } from "./describe.ts";
|
|
4
|
+
import { DataEngine, DataError } from "./engine-client.ts";
|
|
5
|
+
import type { SheetSummary } from "./engine-types.ts";
|
|
6
|
+
import { PageParamError } from "./rows.ts";
|
|
7
|
+
export { DataError, PageParamError };
|
|
8
|
+
export interface DatasetLimits {
|
|
9
|
+
maxUploadBytes: number;
|
|
10
|
+
maxRows: number;
|
|
11
|
+
queryTimeoutMs: number;
|
|
12
|
+
maxInteractiveRows: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_DATASET_LIMITS: DatasetLimits;
|
|
15
|
+
export declare const XLSX_REMEDY = "Export the sheet as CSV, or install Python 3.10+.";
|
|
16
|
+
/** The format of a file name, or undefined when it is not a supported dataset. */
|
|
17
|
+
export declare function datasetFormat(name: string): DatasetFormat | undefined;
|
|
18
|
+
export interface DatasetRecord {
|
|
19
|
+
id: string;
|
|
20
|
+
/** The session that created it. */
|
|
21
|
+
sessionId: string;
|
|
22
|
+
rootSessionId: string;
|
|
23
|
+
name: string;
|
|
24
|
+
format: DatasetFormat;
|
|
25
|
+
sha256: string;
|
|
26
|
+
bytes: number;
|
|
27
|
+
blobHash?: string;
|
|
28
|
+
sourcePath?: string;
|
|
29
|
+
dbRelPath: string;
|
|
30
|
+
ingestVersion: number;
|
|
31
|
+
sheets: SheetSummary[];
|
|
32
|
+
createdAt: number;
|
|
33
|
+
}
|
|
34
|
+
export declare const toDatasetRef: (record: DatasetRecord) => DatasetRef;
|
|
35
|
+
export interface IngestInput {
|
|
36
|
+
rootSessionId: string;
|
|
37
|
+
sessionId: string;
|
|
38
|
+
workspace: string;
|
|
39
|
+
/** Absolute path of the file to read (resolved and authorised by the caller). */
|
|
40
|
+
path: string;
|
|
41
|
+
/** Display name; defaults to the file name. */
|
|
42
|
+
name?: string;
|
|
43
|
+
blobHash?: string;
|
|
44
|
+
/** Recorded as `source_path` (a workspace or artifact file); never sent to clients. */
|
|
45
|
+
sourcePath?: string;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
onProgress?: (rows: number) => void;
|
|
48
|
+
}
|
|
49
|
+
export interface DatasetServiceOptions {
|
|
50
|
+
/** State folder (`stateHome()` or the folder of `--db`). */
|
|
51
|
+
root: string;
|
|
52
|
+
db: SqlDatabase;
|
|
53
|
+
limits?: Partial<DatasetLimits>;
|
|
54
|
+
/** The Python interpreter (XLSX only); absent means XLSX is unsupported. */
|
|
55
|
+
python?: {
|
|
56
|
+
interpreter(signal: AbortSignal): Promise<PythonResolution>;
|
|
57
|
+
};
|
|
58
|
+
engine?: DataEngine;
|
|
59
|
+
now?: () => number;
|
|
60
|
+
}
|
|
61
|
+
export declare class DatasetService {
|
|
62
|
+
private readonly options;
|
|
63
|
+
readonly limits: DatasetLimits;
|
|
64
|
+
readonly engine: DataEngine;
|
|
65
|
+
private inflight;
|
|
66
|
+
constructor(options: DatasetServiceOptions);
|
|
67
|
+
private now;
|
|
68
|
+
/** Absolute path of a dataset file. */
|
|
69
|
+
file(record: DatasetRecord): string;
|
|
70
|
+
get(id: string): DatasetRecord | undefined;
|
|
71
|
+
private touched;
|
|
72
|
+
/**
|
|
73
|
+
* Records that a dataset was used, for the retention sweep ("unused for `jobsDays`"): the
|
|
74
|
+
* modification time of its SQLite file (nothing else changes it after the ingestion), at most
|
|
75
|
+
* once every ten minutes per dataset. Best effort: a failure never affects the caller.
|
|
76
|
+
*/
|
|
77
|
+
private touch;
|
|
78
|
+
/** Every dataset (all sessions), for the retention sweep; does not count as a use. */
|
|
79
|
+
all(): DatasetRecord[];
|
|
80
|
+
/** A dataset of this root session; another session's id is `not_found`. */
|
|
81
|
+
getFor(id: string, rootSessionId: string): DatasetRecord;
|
|
82
|
+
list(rootSessionId: string): DatasetRecord[];
|
|
83
|
+
private bySha;
|
|
84
|
+
/**
|
|
85
|
+
* Ingests a file into a dataset, or returns the session's existing dataset with the same
|
|
86
|
+
* content (`reused`). Concurrent calls for the same content share one ingestion. A failure
|
|
87
|
+
* leaves no row and no file.
|
|
88
|
+
*/
|
|
89
|
+
ingest(input: IngestInput): Promise<{
|
|
90
|
+
record: DatasetRecord;
|
|
91
|
+
reused: boolean;
|
|
92
|
+
}>;
|
|
93
|
+
private build;
|
|
94
|
+
/** The XLSX route (spec §17.6, option B): the Python standard-library helper, then statistics. */
|
|
95
|
+
private ingestXlsx;
|
|
96
|
+
/** The `alisio_runtime` package on disk for `python -m` (content-addressed, written once). */
|
|
97
|
+
private helperDir;
|
|
98
|
+
/** Schema and statistics of every sheet (`GET /api/datasets/:did`). */
|
|
99
|
+
detail(record: DatasetRecord): DatasetDetailWire;
|
|
100
|
+
/** First rows of a table, for `data_inspect` and the prompt summary. */
|
|
101
|
+
sample(record: DatasetRecord, table: string, rows: number, signal?: AbortSignal): Promise<SampleRows>;
|
|
102
|
+
/** The description for the model: `data_inspect` text. */
|
|
103
|
+
describe(record: DatasetRecord, sampleRows: number, maxChars: number, signal?: AbortSignal): Promise<{
|
|
104
|
+
detail: DatasetDetailWire;
|
|
105
|
+
samples: Map<string, SampleRows>;
|
|
106
|
+
text: string;
|
|
107
|
+
}>;
|
|
108
|
+
/** The ≤ 4 KB summary appended to a prompt that attaches the dataset. */
|
|
109
|
+
summary(record: DatasetRecord, signal?: AbortSignal): Promise<string>;
|
|
110
|
+
/**
|
|
111
|
+
* Runs a model-written read-only query (`data_query`): lexical guard, then a read-only
|
|
112
|
+
* connection in the engine process, with a row limit and a time limit.
|
|
113
|
+
*/
|
|
114
|
+
query(record: DatasetRecord, sql: string, options?: {
|
|
115
|
+
maxRows?: number;
|
|
116
|
+
signal?: AbortSignal;
|
|
117
|
+
}): Promise<import("./engine-types.ts").QueryResult>;
|
|
118
|
+
/** One page of a sheet for `SpreadsheetView` (spec §17.4). */
|
|
119
|
+
page(record: DatasetRecord, request: {
|
|
120
|
+
sheet?: string;
|
|
121
|
+
after?: string;
|
|
122
|
+
offset?: number;
|
|
123
|
+
limit?: number;
|
|
124
|
+
sort?: string;
|
|
125
|
+
dir?: "asc" | "desc";
|
|
126
|
+
filter?: string;
|
|
127
|
+
column?: string;
|
|
128
|
+
}, signal?: AbortSignal): Promise<DatasetRowsPage>;
|
|
129
|
+
/** Deletes a dataset file and row (connections are closed first, for Windows). */
|
|
130
|
+
remove(record: DatasetRecord): Promise<void>;
|
|
131
|
+
close(): void;
|
|
132
|
+
}
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datasets of a session (spec §17): one SQLite file per dataset under
|
|
3
|
+
* `<state>/analysis/datasets/<workspaceKey>/<rootSession>/<datasetId>.sqlite`, the `datasets` rows
|
|
4
|
+
* that describe them, ingestion (Node engine for CSV/TSV/JSON/JSONL, the Python standard-library
|
|
5
|
+
* helper for XLSX), read-only queries, and the pages `SpreadsheetView` reads. Files are
|
|
6
|
+
* regenerable derivations: the original (a blob or a workspace/artifact file) is never altered.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { createReadStream } from "node:fs";
|
|
10
|
+
import { chmod, mkdir, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
11
|
+
import { basename, extname, join, relative } from "node:path";
|
|
12
|
+
import { newId } from "../../runtime/ids.js";
|
|
13
|
+
import { workspaceKey } from "../../runtime/paths.js";
|
|
14
|
+
import { runProcess } from "../../runtime/process.js";
|
|
15
|
+
import { openReadOnlyDatabase } from "../../runtime/sqlite.js";
|
|
16
|
+
import { ALISIO_RUNTIME_FILES } from "../python/sources.js";
|
|
17
|
+
import { describeDataset, promptSummary } from "./describe.js";
|
|
18
|
+
import { DataEngine, DataError } from "./engine-client.js";
|
|
19
|
+
import { buildCountQuery, buildPageQuery, encodeCursor, MAX_ALL_COLUMNS_FILTER_ROWS, MAX_PAGE_ROWS, PageParamError, } from "./rows.js";
|
|
20
|
+
import { guardSql } from "./sql-guard.js";
|
|
21
|
+
import { INGEST_VERSION } from "./stats.js";
|
|
22
|
+
export { DataError, PageParamError };
|
|
23
|
+
export const DEFAULT_DATASET_LIMITS = {
|
|
24
|
+
maxUploadBytes: 200 * 1024 * 1024,
|
|
25
|
+
maxRows: 5_000_000,
|
|
26
|
+
queryTimeoutMs: 5000,
|
|
27
|
+
maxInteractiveRows: 1_000_000,
|
|
28
|
+
};
|
|
29
|
+
const MAX_COLUMNS = 1000;
|
|
30
|
+
const MAX_CELL_CHARS = 1024 * 1024;
|
|
31
|
+
const XLSX_TIMEOUT_MS = 10 * 60_000;
|
|
32
|
+
export const XLSX_REMEDY = "Export the sheet as CSV, or install Python 3.10+.";
|
|
33
|
+
/** Heap soft cap of SQLite in the query process (`PRAGMA hard_heap_limit`). */
|
|
34
|
+
const QUERY_HEAP_BYTES = 512 * 1024 * 1024;
|
|
35
|
+
const FORMATS = {
|
|
36
|
+
".csv": "csv",
|
|
37
|
+
".tsv": "tsv",
|
|
38
|
+
".tab": "tsv",
|
|
39
|
+
".json": "json",
|
|
40
|
+
".jsonl": "jsonl",
|
|
41
|
+
".ndjson": "jsonl",
|
|
42
|
+
".xlsx": "xlsx",
|
|
43
|
+
};
|
|
44
|
+
/** The format of a file name, or undefined when it is not a supported dataset. */
|
|
45
|
+
export function datasetFormat(name) {
|
|
46
|
+
return FORMATS[extname(name).toLowerCase()];
|
|
47
|
+
}
|
|
48
|
+
const toRecord = (row) => ({
|
|
49
|
+
id: row.id,
|
|
50
|
+
sessionId: row.session,
|
|
51
|
+
rootSessionId: row.root_session,
|
|
52
|
+
name: row.name,
|
|
53
|
+
format: row.format,
|
|
54
|
+
sha256: row.sha256,
|
|
55
|
+
bytes: row.bytes,
|
|
56
|
+
...(row.blob_hash ? { blobHash: row.blob_hash } : {}),
|
|
57
|
+
...(row.source_path ? { sourcePath: row.source_path } : {}),
|
|
58
|
+
dbRelPath: row.db_rel_path,
|
|
59
|
+
ingestVersion: row.ingest_version,
|
|
60
|
+
sheets: JSON.parse(row.sheets),
|
|
61
|
+
createdAt: row.created_at,
|
|
62
|
+
});
|
|
63
|
+
export const toDatasetRef = (record) => ({
|
|
64
|
+
id: record.id,
|
|
65
|
+
name: record.name,
|
|
66
|
+
format: record.format,
|
|
67
|
+
bytes: record.bytes,
|
|
68
|
+
sha256: record.sha256,
|
|
69
|
+
sheets: record.sheets.map((s) => ({ ...s })),
|
|
70
|
+
});
|
|
71
|
+
async function sha256Of(path, signal) {
|
|
72
|
+
const hash = createHash("sha256");
|
|
73
|
+
for await (const chunk of createReadStream(path)) {
|
|
74
|
+
signal?.throwIfAborted();
|
|
75
|
+
hash.update(chunk);
|
|
76
|
+
}
|
|
77
|
+
return hash.digest("hex");
|
|
78
|
+
}
|
|
79
|
+
const message = (error) => (error instanceof Error ? error.message : String(error));
|
|
80
|
+
export class DatasetService {
|
|
81
|
+
options;
|
|
82
|
+
limits;
|
|
83
|
+
engine;
|
|
84
|
+
inflight = new Map();
|
|
85
|
+
constructor(options) {
|
|
86
|
+
this.options = options;
|
|
87
|
+
this.limits = { ...DEFAULT_DATASET_LIMITS, ...options.limits };
|
|
88
|
+
this.engine = options.engine ?? new DataEngine({ root: options.root });
|
|
89
|
+
}
|
|
90
|
+
now() {
|
|
91
|
+
return (this.options.now ?? Date.now)();
|
|
92
|
+
}
|
|
93
|
+
/** Absolute path of a dataset file. */
|
|
94
|
+
file(record) {
|
|
95
|
+
return join(this.options.root, record.dbRelPath);
|
|
96
|
+
}
|
|
97
|
+
get(id) {
|
|
98
|
+
const row = this.options.db.prepare("SELECT * FROM datasets WHERE id=?").get(id);
|
|
99
|
+
const record = row ? toRecord(row) : undefined;
|
|
100
|
+
if (record)
|
|
101
|
+
this.touch(record);
|
|
102
|
+
return record;
|
|
103
|
+
}
|
|
104
|
+
touched = new Map();
|
|
105
|
+
/**
|
|
106
|
+
* Records that a dataset was used, for the retention sweep ("unused for `jobsDays`"): the
|
|
107
|
+
* modification time of its SQLite file (nothing else changes it after the ingestion), at most
|
|
108
|
+
* once every ten minutes per dataset. Best effort: a failure never affects the caller.
|
|
109
|
+
*/
|
|
110
|
+
touch(record) {
|
|
111
|
+
const now = this.now();
|
|
112
|
+
if (now - (this.touched.get(record.id) ?? 0) < 10 * 60_000)
|
|
113
|
+
return;
|
|
114
|
+
this.touched.set(record.id, now);
|
|
115
|
+
const when = new Date(now);
|
|
116
|
+
void utimes(this.file(record), when, when).catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
/** Every dataset (all sessions), for the retention sweep; does not count as a use. */
|
|
119
|
+
all() {
|
|
120
|
+
return this.options.db.prepare("SELECT * FROM datasets").all().map(toRecord);
|
|
121
|
+
}
|
|
122
|
+
/** A dataset of this root session; another session's id is `not_found`. */
|
|
123
|
+
getFor(id, rootSessionId) {
|
|
124
|
+
const record = this.get(id);
|
|
125
|
+
if (!record || record.rootSessionId !== rootSessionId)
|
|
126
|
+
throw new DataError("not_found", `Dataset ${id} not found in this session`);
|
|
127
|
+
return record;
|
|
128
|
+
}
|
|
129
|
+
list(rootSessionId) {
|
|
130
|
+
return this.options.db
|
|
131
|
+
.prepare("SELECT * FROM datasets WHERE root_session=? ORDER BY created_at DESC, id DESC")
|
|
132
|
+
.all(rootSessionId).map(toRecord);
|
|
133
|
+
}
|
|
134
|
+
bySha(rootSessionId, sha256) {
|
|
135
|
+
const row = this.options.db
|
|
136
|
+
.prepare("SELECT * FROM datasets WHERE root_session=? AND sha256=? ORDER BY created_at LIMIT 1")
|
|
137
|
+
.get(rootSessionId, sha256);
|
|
138
|
+
return row ? toRecord(row) : undefined;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Ingests a file into a dataset, or returns the session's existing dataset with the same
|
|
142
|
+
* content (`reused`). Concurrent calls for the same content share one ingestion. A failure
|
|
143
|
+
* leaves no row and no file.
|
|
144
|
+
*/
|
|
145
|
+
async ingest(input) {
|
|
146
|
+
const name = input.name ?? basename(input.path);
|
|
147
|
+
const format = datasetFormat(name);
|
|
148
|
+
if (!format)
|
|
149
|
+
throw new DataError("dataset_unsupported", `Unsupported file type ${extname(name) || "(none)"}. Supported: CSV, TSV, JSON, JSONL and XLSX.`);
|
|
150
|
+
const info = await stat(input.path).catch(() => undefined);
|
|
151
|
+
if (!info?.isFile())
|
|
152
|
+
throw new DataError("not_found", `File not found: ${name}`);
|
|
153
|
+
if (info.size > this.limits.maxUploadBytes)
|
|
154
|
+
throw new DataError("ingest_limit", `${name} is ${(info.size / (1024 * 1024)).toFixed(1)} MiB; the limit is ${Math.round(this.limits.maxUploadBytes / (1024 * 1024))} MiB (analysis.data.maxUploadBytes)`);
|
|
155
|
+
if (info.size === 0)
|
|
156
|
+
throw new DataError("ingest_invalid", `${name} is empty`);
|
|
157
|
+
const sha256 = await sha256Of(input.path, input.signal);
|
|
158
|
+
const existing = this.bySha(input.rootSessionId, sha256);
|
|
159
|
+
if (existing)
|
|
160
|
+
return { record: existing, reused: true };
|
|
161
|
+
const key = `${input.rootSessionId}:${sha256}`;
|
|
162
|
+
let pending = this.inflight.get(key);
|
|
163
|
+
if (!pending) {
|
|
164
|
+
pending = this.build(input, name, format, sha256, info.size).finally(() => this.inflight.delete(key));
|
|
165
|
+
this.inflight.set(key, pending);
|
|
166
|
+
}
|
|
167
|
+
return pending;
|
|
168
|
+
}
|
|
169
|
+
async build(input, name, format, sha256, bytes) {
|
|
170
|
+
const id = newId("ds", this.now());
|
|
171
|
+
const dir = join(this.options.root, "analysis", "datasets", workspaceKey(input.workspace), input.rootSessionId);
|
|
172
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
173
|
+
const target = join(dir, `${id}.sqlite`);
|
|
174
|
+
const temp = `${target}.tmp`;
|
|
175
|
+
try {
|
|
176
|
+
let result;
|
|
177
|
+
if (format === "xlsx")
|
|
178
|
+
result = await this.ingestXlsx(input, name, sha256, temp);
|
|
179
|
+
else
|
|
180
|
+
result = await this.engine.ingest({
|
|
181
|
+
source: input.path,
|
|
182
|
+
format,
|
|
183
|
+
target: temp,
|
|
184
|
+
sourceName: name,
|
|
185
|
+
sourceSha256: sha256,
|
|
186
|
+
limits: {
|
|
187
|
+
maxRows: this.limits.maxRows,
|
|
188
|
+
maxColumns: MAX_COLUMNS,
|
|
189
|
+
maxCellChars: MAX_CELL_CHARS,
|
|
190
|
+
},
|
|
191
|
+
}, {
|
|
192
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
193
|
+
...(input.onProgress ? { onProgress: input.onProgress } : {}),
|
|
194
|
+
});
|
|
195
|
+
// Windows cannot rename over an existing file; nothing else holds this fresh path.
|
|
196
|
+
await rm(target, { force: true });
|
|
197
|
+
await rename(temp, target);
|
|
198
|
+
await chmod(target, 0o400).catch(() => undefined);
|
|
199
|
+
const record = {
|
|
200
|
+
id,
|
|
201
|
+
sessionId: input.sessionId,
|
|
202
|
+
rootSessionId: input.rootSessionId,
|
|
203
|
+
name,
|
|
204
|
+
format,
|
|
205
|
+
sha256,
|
|
206
|
+
bytes,
|
|
207
|
+
...(input.blobHash ? { blobHash: input.blobHash } : {}),
|
|
208
|
+
...(input.sourcePath ? { sourcePath: input.sourcePath } : {}),
|
|
209
|
+
dbRelPath: relative(this.options.root, target),
|
|
210
|
+
ingestVersion: INGEST_VERSION,
|
|
211
|
+
sheets: result.sheets,
|
|
212
|
+
createdAt: this.now(),
|
|
213
|
+
};
|
|
214
|
+
this.options.db
|
|
215
|
+
.prepare(`INSERT INTO datasets(id,session,root_session,name,format,sha256,bytes,blob_hash,source_path,
|
|
216
|
+
db_rel_path,ingest_version,sheets,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
217
|
+
.run(record.id, record.sessionId, record.rootSessionId, record.name, record.format, record.sha256, record.bytes, record.blobHash ?? null, record.sourcePath ?? null, record.dbRelPath, record.ingestVersion, JSON.stringify(record.sheets), record.createdAt);
|
|
218
|
+
return { record, reused: false };
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
222
|
+
await rm(target, { force: true }).catch(() => undefined);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** The XLSX route (spec §17.6, option B): the Python standard-library helper, then statistics. */
|
|
227
|
+
async ingestXlsx(input, name, sha256, temp) {
|
|
228
|
+
const python = this.options.python;
|
|
229
|
+
const signal = input.signal ?? new AbortController().signal;
|
|
230
|
+
const runtime = python ? await python.interpreter(signal) : undefined;
|
|
231
|
+
if (!runtime?.ok)
|
|
232
|
+
throw new DataError("dataset_unsupported", `${name} is an Excel workbook, which needs Python 3.10+ to be read. ${XLSX_REMEDY}`);
|
|
233
|
+
const helper = await this.helperDir();
|
|
234
|
+
await rm(temp, { force: true });
|
|
235
|
+
let run;
|
|
236
|
+
try {
|
|
237
|
+
run = await runProcess(runtime.executable, [
|
|
238
|
+
...(runtime.prefixArgs ?? []),
|
|
239
|
+
"-E",
|
|
240
|
+
"-s",
|
|
241
|
+
"-B",
|
|
242
|
+
"-u",
|
|
243
|
+
"-X",
|
|
244
|
+
"utf8",
|
|
245
|
+
"-m",
|
|
246
|
+
"alisio_runtime.xlsx_to_sqlite",
|
|
247
|
+
input.path,
|
|
248
|
+
temp,
|
|
249
|
+
"--max-rows",
|
|
250
|
+
String(this.limits.maxRows),
|
|
251
|
+
"--max-columns",
|
|
252
|
+
String(MAX_COLUMNS),
|
|
253
|
+
"--max-cell-chars",
|
|
254
|
+
String(MAX_CELL_CHARS),
|
|
255
|
+
], { cwd: helper, signal, timeoutMs: XLSX_TIMEOUT_MS, maxBytes: 16 * 1024 });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (signal.aborted)
|
|
259
|
+
throw new DataError("cancelled", "Cancelled");
|
|
260
|
+
throw new DataError("engine_error", `The XLSX helper failed: ${message(error)}`);
|
|
261
|
+
}
|
|
262
|
+
if (run.exitCode !== 0) {
|
|
263
|
+
const line = run.stderr
|
|
264
|
+
.split(/\r?\n/)
|
|
265
|
+
.reverse()
|
|
266
|
+
.find((l) => l.startsWith("alisio-xlsx:")) ?? "";
|
|
267
|
+
throw new DataError("ingest_invalid", line
|
|
268
|
+
? line.replace(/^alisio-xlsx:\s*/, "")
|
|
269
|
+
: `The XLSX helper failed (exit ${run.exitCode})`);
|
|
270
|
+
}
|
|
271
|
+
return this.engine.finalize({ target: temp, sourceName: name, sourceSha256: sha256 }, {
|
|
272
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/** The `alisio_runtime` package on disk for `python -m` (content-addressed, written once). */
|
|
276
|
+
async helperDir() {
|
|
277
|
+
const hash = createHash("sha256")
|
|
278
|
+
.update(JSON.stringify(ALISIO_RUNTIME_FILES))
|
|
279
|
+
.digest("hex")
|
|
280
|
+
.slice(0, 12);
|
|
281
|
+
const dir = join(this.options.root, "runtimes", "python", "helper", hash);
|
|
282
|
+
const marker = join(dir, ".complete");
|
|
283
|
+
if (await stat(marker).then(() => true, () => false))
|
|
284
|
+
return dir;
|
|
285
|
+
const package_ = join(dir, "alisio_runtime");
|
|
286
|
+
await mkdir(package_, { recursive: true, mode: 0o700 });
|
|
287
|
+
for (const [file, text] of Object.entries(ALISIO_RUNTIME_FILES))
|
|
288
|
+
await writeFile(join(package_, file), text);
|
|
289
|
+
await writeFile(marker, "");
|
|
290
|
+
return dir;
|
|
291
|
+
}
|
|
292
|
+
/** Schema and statistics of every sheet (`GET /api/datasets/:did`). */
|
|
293
|
+
detail(record) {
|
|
294
|
+
const db = openReadOnlyDatabase(this.file(record));
|
|
295
|
+
try {
|
|
296
|
+
const meta = Object.fromEntries(db.prepare("SELECT key, value FROM _alisio_meta").all().map((r) => [r.key, r.value]));
|
|
297
|
+
const sheets = db
|
|
298
|
+
.prepare("SELECT name, table_name, rows FROM _alisio_sheets ORDER BY ordinal")
|
|
299
|
+
.all();
|
|
300
|
+
const columns = db.prepare(`SELECT name, label, inferred_type, nulls, distinct_count, distinct_exact, min, max, mean,
|
|
301
|
+
text_fallbacks, top_values FROM _alisio_columns WHERE table_name=? ORDER BY ordinal`);
|
|
302
|
+
return {
|
|
303
|
+
...toDatasetRef(record),
|
|
304
|
+
ingestVersion: record.ingestVersion,
|
|
305
|
+
...(meta.encoding ? { encoding: meta.encoding } : {}),
|
|
306
|
+
...(meta.delimiter ? { delimiter: meta.delimiter } : {}),
|
|
307
|
+
maxInteractiveRows: this.limits.maxInteractiveRows,
|
|
308
|
+
sheetDetails: sheets.map((sheet) => ({
|
|
309
|
+
name: sheet.name,
|
|
310
|
+
table: sheet.table_name,
|
|
311
|
+
rows: Number(sheet.rows),
|
|
312
|
+
columns: columns.all(sheet.table_name).map((c) => ({
|
|
313
|
+
name: String(c.name),
|
|
314
|
+
label: String(c.label),
|
|
315
|
+
type: String(c.inferred_type),
|
|
316
|
+
nulls: Number(c.nulls ?? 0),
|
|
317
|
+
distinct: Number(c.distinct_count ?? 0),
|
|
318
|
+
distinctExact: Number(c.distinct_exact ?? 1) === 1,
|
|
319
|
+
...(c.min !== null && c.min !== undefined ? { min: String(c.min) } : {}),
|
|
320
|
+
...(c.max !== null && c.max !== undefined ? { max: String(c.max) } : {}),
|
|
321
|
+
...(c.mean !== null && c.mean !== undefined ? { mean: Number(c.mean) } : {}),
|
|
322
|
+
textFallbacks: Number(c.text_fallbacks ?? 0),
|
|
323
|
+
top: JSON.parse(String(c.top_values ?? "[]")),
|
|
324
|
+
})),
|
|
325
|
+
})),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
db.close();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** First rows of a table, for `data_inspect` and the prompt summary. */
|
|
333
|
+
async sample(record, table, rows, signal) {
|
|
334
|
+
const result = await this.engine.runQuery({
|
|
335
|
+
db: this.file(record),
|
|
336
|
+
sql: `SELECT * FROM "${table.replaceAll('"', '""')}" LIMIT ${Math.max(0, Math.floor(rows))}`,
|
|
337
|
+
maxRows: rows,
|
|
338
|
+
cellChars: 200,
|
|
339
|
+
guard: false,
|
|
340
|
+
}, { timeoutMs: this.limits.queryTimeoutMs, ...(signal ? { signal } : {}) });
|
|
341
|
+
return { table, columns: result.columns, rows: result.rows };
|
|
342
|
+
}
|
|
343
|
+
/** The description for the model: `data_inspect` text. */
|
|
344
|
+
async describe(record, sampleRows, maxChars, signal) {
|
|
345
|
+
const detail = this.detail(record);
|
|
346
|
+
const samples = new Map();
|
|
347
|
+
for (const sheet of detail.sheetDetails.slice(0, 5))
|
|
348
|
+
samples.set(sheet.table, await this.sample(record, sheet.table, sampleRows, signal));
|
|
349
|
+
return { detail, samples, text: describeDataset(detail, samples, { maxChars }) };
|
|
350
|
+
}
|
|
351
|
+
/** The ≤ 4 KB summary appended to a prompt that attaches the dataset. */
|
|
352
|
+
async summary(record, signal) {
|
|
353
|
+
const detail = this.detail(record);
|
|
354
|
+
const samples = new Map();
|
|
355
|
+
for (const sheet of detail.sheetDetails.slice(0, 3))
|
|
356
|
+
samples.set(sheet.table, await this.sample(record, sheet.table, 5, signal));
|
|
357
|
+
return promptSummary(detail, samples);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Runs a model-written read-only query (`data_query`): lexical guard, then a read-only
|
|
361
|
+
* connection in the engine process, with a row limit and a time limit.
|
|
362
|
+
*/
|
|
363
|
+
async query(record, sql, options = {}) {
|
|
364
|
+
const guarded = guardSql(sql);
|
|
365
|
+
if (!guarded.ok)
|
|
366
|
+
throw new DataError("query_rejected", guarded.reason);
|
|
367
|
+
return this.engine.runQuery({
|
|
368
|
+
db: this.file(record),
|
|
369
|
+
sql: guarded.sql,
|
|
370
|
+
maxRows: Math.min(Math.max(1, options.maxRows ?? 200), 1000),
|
|
371
|
+
cellChars: 2048,
|
|
372
|
+
guard: true,
|
|
373
|
+
heapBytes: QUERY_HEAP_BYTES,
|
|
374
|
+
}, {
|
|
375
|
+
timeoutMs: this.limits.queryTimeoutMs,
|
|
376
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/** One page of a sheet for `SpreadsheetView` (spec §17.4). */
|
|
380
|
+
async page(record, request, signal) {
|
|
381
|
+
const detail = this.detail(record);
|
|
382
|
+
const sheet = detail.sheetDetails.find((s) => s.name === request.sheet || s.table === request.sheet) ??
|
|
383
|
+
(request.sheet === undefined ? detail.sheetDetails[0] : undefined);
|
|
384
|
+
if (!sheet)
|
|
385
|
+
throw new PageParamError(`Unknown sheet: ${request.sheet}`);
|
|
386
|
+
const interactive = sheet.rows <= this.limits.maxInteractiveRows;
|
|
387
|
+
const names = sheet.columns.map((c) => c.name);
|
|
388
|
+
const filterText = interactive ? (request.filter ?? "") : "";
|
|
389
|
+
if (filterText && request.column === undefined && sheet.rows > MAX_ALL_COLUMNS_FILTER_ROWS)
|
|
390
|
+
throw new PageParamError(`Choose a column to filter: this sheet has more than ${MAX_ALL_COLUMNS_FILTER_ROWS} rows`);
|
|
391
|
+
const params = {
|
|
392
|
+
table: sheet.table,
|
|
393
|
+
columns: names,
|
|
394
|
+
limit: Math.min(Math.max(1, request.limit ?? 200), MAX_PAGE_ROWS),
|
|
395
|
+
...(interactive && request.sort
|
|
396
|
+
? { sort: { column: request.sort, dir: request.dir ?? "asc" } }
|
|
397
|
+
: {}),
|
|
398
|
+
...(filterText
|
|
399
|
+
? {
|
|
400
|
+
filter: {
|
|
401
|
+
text: filterText,
|
|
402
|
+
...(request.column !== undefined ? { column: request.column } : {}),
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
: {}),
|
|
406
|
+
...(request.after ? { after: request.after } : {}),
|
|
407
|
+
...(request.offset ? { offset: request.offset } : {}),
|
|
408
|
+
};
|
|
409
|
+
const built = buildPageQuery(params);
|
|
410
|
+
const run = (sql, values, maxRows) => this.engine.runQuery({ db: this.file(record), sql, params: values, maxRows, cellChars: 4096, guard: false }, { timeoutMs: Math.max(this.limits.queryTimeoutMs, 30_000), ...(signal ? { signal } : {}) });
|
|
411
|
+
const result = await run(built.sql, built.params, params.limit + 1);
|
|
412
|
+
const hasNext = result.rows.length > params.limit;
|
|
413
|
+
const page = hasNext ? result.rows.slice(0, params.limit) : result.rows;
|
|
414
|
+
const rowids = page.map((r) => Number(r[0]));
|
|
415
|
+
let matched;
|
|
416
|
+
if (filterText && !request.after && !request.offset) {
|
|
417
|
+
const count = buildCountQuery({ table: sheet.table, columns: names, filter: params.filter });
|
|
418
|
+
const counted = await run(count.sql, count.params, 1);
|
|
419
|
+
matched = Number(counted.rows[0]?.[0] ?? 0);
|
|
420
|
+
}
|
|
421
|
+
const last = rowids.at(-1);
|
|
422
|
+
return {
|
|
423
|
+
columns: sheet.columns.map((c) => ({ name: c.name, label: c.label, type: c.type })),
|
|
424
|
+
rows: page.map((r) => r.slice(1)),
|
|
425
|
+
rowids,
|
|
426
|
+
...(hasNext && last !== undefined ? { next: encodeCursor(last) } : {}),
|
|
427
|
+
total: sheet.rows,
|
|
428
|
+
...(matched !== undefined ? { matched } : {}),
|
|
429
|
+
interactive,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/** Deletes a dataset file and row (connections are closed first, for Windows). */
|
|
433
|
+
async remove(record) {
|
|
434
|
+
await this.engine.closeFile(this.file(record));
|
|
435
|
+
await rm(this.file(record), { force: true });
|
|
436
|
+
this.options.db.prepare("DELETE FROM datasets WHERE id=?").run(record.id);
|
|
437
|
+
}
|
|
438
|
+
close() {
|
|
439
|
+
this.engine.close();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text and table renderings of a dataset for the model and the terminal (spec §12 and the
|
|
3
|
+
* prompt summary of §21 phase 3). Pure: given the dataset detail and sample rows, produces a
|
|
4
|
+
* bounded text (the model never sees more than `maxChars`).
|
|
5
|
+
*/
|
|
6
|
+
import type { DatasetDetailWire } from "@alisio/sdk";
|
|
7
|
+
export declare const formatCount: (n: number) => string;
|
|
8
|
+
export declare function formatBytes(bytes: number): string;
|
|
9
|
+
/** One cell as plain, single-line text of at most `max` characters. */
|
|
10
|
+
export declare function cellText(value: string | number | null | undefined, max?: number): string;
|
|
11
|
+
/** `ds_… "sales.csv" · csv · 1.2 MB · 12,480 rows × 6 columns` (one line per dataset). */
|
|
12
|
+
export declare function datasetHeadline(detail: DatasetDetailWire): string;
|
|
13
|
+
export interface SampleRows {
|
|
14
|
+
table: string;
|
|
15
|
+
columns: string[];
|
|
16
|
+
rows: Array<Array<string | number | null>>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The description a model reads: headline, usage note, and for each sheet its columns with
|
|
20
|
+
* statistics and a few sample rows. Fits `maxChars` by dropping column detail first, then sample
|
|
21
|
+
* columns (never silently: a line says what was left out).
|
|
22
|
+
*/
|
|
23
|
+
export declare function describeDataset(detail: DatasetDetailWire, samples: Map<string, SampleRows>, options: {
|
|
24
|
+
maxChars: number;
|
|
25
|
+
}): string;
|
|
26
|
+
/** The summary appended to the user's prompt for an attached dataset (≤ 4 KB). */
|
|
27
|
+
export declare function promptSummary(detail: DatasetDetailWire, samples: Map<string, SampleRows>): string;
|