Repository navigation
Expand file tree
/
Copy pathapi.py
More file actions
389 lines (362 loc) · 14.4 KB
/
Copy pathapi.py
File metadata and controls
389 lines (362 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
"""Small, file-oriented API shared by notebooks, scripts, and Agent Skills."""
from __future__ import annotations
import hashlib
import json
import shutil
import tempfile
import uuid
from collections.abc import Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
from hyperknowledge.bundle import (
BUNDLE_SCHEMA_VERSION,
BundleExportError,
_json_bytes,
_stable_id,
_write_bundle_tables,
export_bundle,
read_bundle,
validate_bundle,
)
from hyperknowledge.utils.template_engine import Template
@dataclass(frozen=True)
class GraphResult:
"""Paths and structural counts, not a model confidence score."""
ka_path: Path | None
bundle_path: Path
node_count: int
hyperedge_count: int
pairwise_count: int
warnings: tuple[str, ...]
def to_dict(self) -> dict[str, Any]:
"""Return a JSON-serializable receipt."""
return {
"ka_path": str(self.ka_path) if self.ka_path else None,
"bundle_path": str(self.bundle_path),
"node_count": self.node_count,
"hyperedge_count": self.hyperedge_count,
"pairwise_count": self.pairwise_count,
"warnings": list(self.warnings),
}
@contextmanager
def _staged_output(output_dir: str | Path, *, force: bool):
"""Validate first, publish last; keep an existing output as a sibling backup."""
target = Path(output_dir).expanduser().resolve()
if (
target in {Path(target.anchor), Path.home().resolve(), Path.cwd().resolve()}
or (target / ".git").exists()
):
raise ValueError(
"Choose a dedicated output directory, not a home, drive, repository, or working directory."
)
if target.exists() and not target.is_dir():
raise FileExistsError(f"Output is not a directory: {target}")
if target.exists() and any(target.iterdir()) and not force:
raise FileExistsError(
f"Output directory is not empty: {target}; choose a new directory or pass force=True."
)
target.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(
prefix=f".{target.name}-staging-", dir=target.parent
) as temporary:
stage = Path(temporary) / "result"
stage.mkdir()
yield stage
backup = None
if target.exists():
backup = target.with_name(f"{target.name}.backup-{uuid.uuid4().hex[:12]}")
target.replace(backup)
try:
stage.replace(target)
except OSError:
if backup is not None and not target.exists():
backup.replace(target)
raise
def _assert_valid(bundle: Path, quality: str) -> dict[str, Any]:
receipt = validate_bundle(bundle, quality=quality)
if receipt["status"] != "passed":
errors = [
f"{d['code']}: {d['subject']} ({d['evidence']})"
for d in receipt.get("diagnostics", [])
if d["severity"] == "error"
]
raise BundleExportError("Invalid graph: " + "; ".join(errors))
return receipt
def _result(bundle: Path, ka: Path | None = None) -> GraphResult:
data = read_bundle(bundle)
receipt = _assert_valid(bundle, "standard")
warnings = tuple(
dict.fromkeys(
[
*data["manifest"].get("limitations", []),
*(
f"{d['code']}: {d['subject']}"
for d in receipt.get("diagnostics", [])
if d["severity"] == "warning"
),
]
)
)
return GraphResult(
ka,
bundle,
len(data["nodes"]),
sum(row["topology"] == "hyperedge" for row in data["assertions"]),
sum(row["topology"] == "pairwise" for row in data["assertions"]),
warnings,
)
def _source_name(name: str) -> str:
if not isinstance(name, str) or not name.strip() or "\\" in name or ":" in name:
raise ValueError("Source names must be nonempty relative POSIX paths.")
path = PurePosixPath(name)
if path.is_absolute() or ".." in path.parts or str(path) == ".":
raise ValueError(f"Unsafe source name: {name!r}")
return path.as_posix()
def _copy_sources(
stage: Path, source_bytes: Mapping[str, bytes]
) -> tuple[list[dict[str, Any]], dict[str, str]]:
records, names = [], {}
for name, content in source_bytes.items():
relative = f"sources/{_source_name(name)}"
if relative.casefold() in {value.casefold() for value in names.values()}:
raise ValueError(f"Duplicate source name: {name}")
path = stage / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
names[name] = relative
records.append(
{
"path": relative,
"size_bytes": len(content),
"sha256": hashlib.sha256(content).hexdigest(),
}
)
return records, names
def import_graph(
data: Mapping[str, Any] | str | Path,
*,
output_dir: str | Path,
sources: Mapping[str, str | Path] | None = None,
language: str = "zh",
quality: str = "standard",
force: bool = False,
) -> GraphResult:
"""Import Bundle-v1 tables without a model or index.
``data`` is a mapping or a UTF-8 JSON file containing ``nodes``,
``assertions``, ``members`` and optional ``evidence`` arrays. Member
ordinals and resolved flags, counts, and hashes are computed locally.
``sources`` maps logical evidence source names to explicitly allowed files;
those files are copied into the bundle. Paths embedded in JSON are never read.
Invalid input is rejected before replacing any existing output.
"""
if quality not in {"standard", "showcase"}:
raise ValueError("quality must be 'standard' or 'showcase'")
if isinstance(data, (str, Path)):
data = json.loads(Path(data).read_text(encoding="utf-8-sig"))
if not isinstance(data, Mapping):
raise ValueError(
"Graph input must be an object with nodes, assertions, members and evidence tables."
)
if set(data) - {"schema_version", "nodes", "assertions", "members", "evidence"}:
raise ValueError(
"Unexpected graph fields; pass source files through sources=, not paths embedded in JSON."
)
if data.get("schema_version", BUNDLE_SCHEMA_VERSION) != BUNDLE_SCHEMA_VERSION:
raise ValueError(f"Expected schema_version={BUNDLE_SCHEMA_VERSION}")
tables = {}
for name in ("nodes", "assertions", "members", "evidence"):
rows = data.get(name, [] if name == "evidence" else None)
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
raise ValueError(f"{name} must be an array of objects")
tables[name] = json.loads(json.dumps(rows, ensure_ascii=False, allow_nan=False))
for node in tables["nodes"]:
for key in ("id", "label", "type"):
if not isinstance(node.get(key), str) or not node[key].strip():
raise ValueError(f"Each node needs a nonempty {key}")
node.setdefault("properties", {})
node_ids = {node["id"] for node in tables["nodes"]}
for assertion in tables["assertions"]:
for key in ("id", "predicate"):
if not isinstance(assertion.get(key), str) or not assertion[key].strip():
raise ValueError(f"Each assertion needs a nonempty {key}")
assertion.setdefault("topology", "hyperedge")
assertion.setdefault("semantics", "structured_import")
assertion.setdefault("epistemic_status", "unreviewed_import")
assertion.setdefault("evidence_refs", [])
assertion.setdefault("properties", {})
for row in [*tables["nodes"], *tables["assertions"]]:
if not isinstance(row["properties"], dict):
raise ValueError("properties must be an object")
next_ordinal: dict[str, int] = {}
for member in tables["members"]:
for key in ("assertion_id", "node_id", "role"):
if not isinstance(member.get(key), str) or not member[key].strip():
raise ValueError(f"Each member needs a nonempty {key}")
assertion_id = member["assertion_id"]
member.setdefault("ordinal", next_ordinal.get(assertion_id, 0))
ordinal = member["ordinal"]
if type(ordinal) is not int or ordinal < 0:
raise ValueError("Member ordinal must be a nonnegative integer")
next_ordinal[assertion_id] = max(next_ordinal.get(assertion_id, 0), ordinal + 1)
member["resolved"] = member["node_id"] in node_ids
source_bytes = {}
for name, path in (sources or {}).items():
normalized = _source_name(name)
if normalized.casefold() in {value.casefold() for value in source_bytes}:
raise ValueError(f"Duplicate source name: {name}")
source_bytes[normalized] = Path(path).read_bytes()
destination = Path(output_dir).expanduser().resolve()
with _staged_output(destination, force=force) as stage:
records, names = _copy_sources(stage, source_bytes)
for item in tables["evidence"]:
if item.get("type") in {"source_text_span", "source_text_summary"}:
source = item.get("source")
if not isinstance(source, str) or source not in names:
raise ValueError(
f"Evidence source {source!r} needs an explicit sources= mapping (CLI: --source NAME=PATH)."
)
item["source"] = names[source]
limitations = []
if any(not row["evidence_refs"] for row in tables["assertions"]):
limitations.append(
"Some assertions lack source-level evidence; missing evidence is not inferred during import."
)
manifest = {
"schema_version": BUNDLE_SCHEMA_VERSION,
"bundle_id": _stable_id("bundle", {"tables": tables, "sources": records}),
"source_data_sha256": hashlib.sha256(_json_bytes(data)).hexdigest(),
"source_ka": None,
"template": "structured/import",
"language": language,
"topology_type": "hypergraph"
if any(row["topology"] == "hyperedge" for row in tables["assertions"])
else "graph",
"sources": records,
"limitations": limitations,
}
_write_bundle_tables(stage, manifest, tables)
_assert_valid(stage, quality)
return _result(destination)
def _extract(
text: str,
source_name: str,
raw: bytes,
*,
output_dir: str | Path,
template: str,
language: str,
llm_client: Any,
embedder: Any,
build_index: bool,
force: bool,
) -> GraphResult:
if not isinstance(text, str) or not text.strip():
raise ValueError("Input text must not be empty")
source_name = _source_name(source_name)
config = Template.get(template)
if config is None or config.type not in {
"graph",
"hypergraph",
"temporal_graph",
"spatial_graph",
"spatio_temporal_graph",
}:
raise ValueError("The extraction API requires a graph or hypergraph template")
destination = Path(output_dir).expanduser().resolve()
with _staged_output(destination, force=force) as stage:
ka_path = stage / "ka"
ka_path.mkdir()
records, _ = _copy_sources(ka_path, {source_name: raw})
ka = Template.create(
template,
language=language,
llm_client=llm_client,
embedder=embedder,
defer_embeddings=True,
)
ka.feed_text(text)
ka.metadata["sources"] = records
if Path(template).is_file():
# KA metadata uses the template's declared name on reload.
shutil.copy2(template, ka_path / f"{config.name}.yaml")
if build_index:
ka.build_index()
if build_index:
ka.dump(ka_path)
else:
ka.dump_data(ka_path / "data.json")
ka.dump_metadata(ka_path / "metadata.json")
export_bundle(ka_path, stage / "bundle")
_assert_valid(stage / "bundle", "standard")
manifest_path = stage / "bundle" / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["source_ka"] = str(destination / "ka")
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
return _result(destination / "bundle", destination / "ka")
def extract_text(
text: str,
*,
output_dir: str | Path,
template: str = "general/hypergraph",
language: str = "zh",
source_name: str = "input.txt",
llm_client: Any = None,
embedder: Any = None,
build_index: bool = False,
force: bool = False,
) -> GraphResult:
"""Extract a string through the existing template engine; save KA and Bundle.
Model configuration is read only when ``llm_client`` is omitted. An embedder
is resolved only when an operation uses it. Existing outputs are protected;
``force=True`` retains the previous directory as a sibling backup.
"""
if not isinstance(text, str):
raise ValueError("text must be a string")
return _extract(
text,
source_name,
text.encode("utf-8"),
output_dir=output_dir,
template=template,
language=language,
llm_client=llm_client,
embedder=embedder,
build_index=build_index,
force=force,
)
def extract_file(
path: str | Path,
*,
output_dir: str | Path,
template: str = "general/hypergraph",
language: str = "zh",
llm_client: Any = None,
embedder: Any = None,
build_index: bool = False,
force: bool = False,
) -> GraphResult:
"""Extract one UTF-8 .txt/.md file; retain its exact bytes and source hash."""
source = Path(path).expanduser().resolve()
if source.suffix.lower() not in {".txt", ".md"}:
raise ValueError(
"extract_file supports UTF-8 .txt and .md; convert other formats to text first."
)
if source.is_relative_to(Path(output_dir).expanduser().resolve()):
raise ValueError("The source must be outside the output directory")
raw = source.read_bytes()
return _extract(
raw.decode("utf-8-sig"),
source.name,
raw,
output_dir=output_dir,
template=template,
language=language,
llm_client=llm_client,
embedder=embedder,
build_index=build_index,
force=force,
)