purestrata 0.1.0__py3-none-any.whl
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.
- purestrata/__init__.py +67 -0
- purestrata/cli.py +161 -0
- purestrata/corpus.py +78 -0
- purestrata/export/__init__.py +15 -0
- purestrata/export/finetune.py +214 -0
- purestrata/ingest/__init__.py +7 -0
- purestrata/ingest/cad.py +253 -0
- purestrata/ingest/failures.py +145 -0
- purestrata/ingest/manual.py +404 -0
- purestrata/mcp_server.py +96 -0
- purestrata/pipeline/__init__.py +6 -0
- purestrata/pipeline/chunk.py +66 -0
- purestrata/pipeline/clean.py +31 -0
- purestrata/search.py +110 -0
- purestrata/tools/__init__.py +13 -0
- purestrata/tools/executor.py +185 -0
- purestrata/tools/schemas.py +209 -0
- purestrata/types.py +122 -0
- purestrata-0.1.0.dist-info/METADATA +227 -0
- purestrata-0.1.0.dist-info/RECORD +23 -0
- purestrata-0.1.0.dist-info/WHEEL +4 -0
- purestrata-0.1.0.dist-info/entry_points.txt +2 -0
- purestrata-0.1.0.dist-info/licenses/LICENSE +21 -0
purestrata/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""PureStrata: the open data layer for manufacturing AI.
|
|
2
|
+
|
|
3
|
+
Ingest drawings, spec sheets, and failure knowledge into a portable,
|
|
4
|
+
provenance-tagged corpus, query it from any LLM (MCP server, OpenAI-style
|
|
5
|
+
tools, or plain Python), and export the same corpus as fine-tuning
|
|
6
|
+
datasets.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .corpus import Corpus, write_chunks
|
|
10
|
+
from .export.finetune import (
|
|
11
|
+
build_chat_records,
|
|
12
|
+
build_cot_records,
|
|
13
|
+
build_tool_trace_records,
|
|
14
|
+
write_jsonl,
|
|
15
|
+
)
|
|
16
|
+
from .ingest.cad import ingest_dxf
|
|
17
|
+
from .ingest.failures import ingest_failures
|
|
18
|
+
from .ingest.manual import ingest_manual
|
|
19
|
+
from .search import SearchIndex, has_cjk, tokenize
|
|
20
|
+
from .tools import TOOLS, ToolError, ToolExecutor, anthropic_tools, json_schemas, openai_tools
|
|
21
|
+
from .types import (
|
|
22
|
+
ALL_KINDS,
|
|
23
|
+
DRAWING,
|
|
24
|
+
FAILURE_CASE,
|
|
25
|
+
MANUAL_SECTION,
|
|
26
|
+
PART_RECORD,
|
|
27
|
+
PROCEDURE,
|
|
28
|
+
SAFETY_NOTICE,
|
|
29
|
+
Chunk,
|
|
30
|
+
Provenance,
|
|
31
|
+
derive_doc_id,
|
|
32
|
+
new_chunk,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ALL_KINDS",
|
|
39
|
+
"DRAWING",
|
|
40
|
+
"FAILURE_CASE",
|
|
41
|
+
"MANUAL_SECTION",
|
|
42
|
+
"PART_RECORD",
|
|
43
|
+
"PROCEDURE",
|
|
44
|
+
"SAFETY_NOTICE",
|
|
45
|
+
"TOOLS",
|
|
46
|
+
"Chunk",
|
|
47
|
+
"Corpus",
|
|
48
|
+
"Provenance",
|
|
49
|
+
"SearchIndex",
|
|
50
|
+
"ToolError",
|
|
51
|
+
"ToolExecutor",
|
|
52
|
+
"anthropic_tools",
|
|
53
|
+
"build_chat_records",
|
|
54
|
+
"build_cot_records",
|
|
55
|
+
"build_tool_trace_records",
|
|
56
|
+
"derive_doc_id",
|
|
57
|
+
"has_cjk",
|
|
58
|
+
"ingest_dxf",
|
|
59
|
+
"ingest_failures",
|
|
60
|
+
"ingest_manual",
|
|
61
|
+
"json_schemas",
|
|
62
|
+
"new_chunk",
|
|
63
|
+
"openai_tools",
|
|
64
|
+
"tokenize",
|
|
65
|
+
"write_chunks",
|
|
66
|
+
"write_jsonl",
|
|
67
|
+
]
|
purestrata/cli.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
purestrata ingest-manual <files...> --out corpus/
|
|
4
|
+
purestrata ingest-dxf <files...> --out corpus/
|
|
5
|
+
purestrata ingest-failures <files...> --out corpus/
|
|
6
|
+
purestrata inspect --corpus corpus/
|
|
7
|
+
purestrata search "spindle vibration" --corpus corpus/
|
|
8
|
+
purestrata tools openai # print tool schemas for Grok/OpenAI
|
|
9
|
+
purestrata export cot --corpus corpus/ --out dataset.jsonl
|
|
10
|
+
purestrata serve --corpus corpus/ # MCP server over stdio
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .corpus import Corpus, write_chunks
|
|
21
|
+
from .ingest.cad import ingest_dxf
|
|
22
|
+
from .ingest.failures import ingest_failures
|
|
23
|
+
from .ingest.manual import ingest_manual
|
|
24
|
+
from .tools import ToolError, anthropic_tools, json_schemas, openai_tools
|
|
25
|
+
from .types import ALL_KINDS, derive_doc_id
|
|
26
|
+
|
|
27
|
+
INGEST_COMMANDS = {
|
|
28
|
+
"ingest-manual": ingest_manual,
|
|
29
|
+
"ingest-dxf": ingest_dxf,
|
|
30
|
+
"ingest-failures": ingest_failures,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="purestrata",
|
|
37
|
+
description="The open data layer for manufacturing AI.",
|
|
38
|
+
)
|
|
39
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
40
|
+
|
|
41
|
+
for name, help_text in (
|
|
42
|
+
("ingest-manual", "Ingest Markdown/PDF manuals and spec sheets"),
|
|
43
|
+
("ingest-dxf", "Ingest ASCII DXF CAD drawings"),
|
|
44
|
+
("ingest-failures", "Ingest CSV/JSONL failure logs"),
|
|
45
|
+
):
|
|
46
|
+
p = sub.add_parser(name, help=help_text)
|
|
47
|
+
p.add_argument("paths", nargs="+", help="Input file paths")
|
|
48
|
+
p.add_argument("--out", required=True, help="Output corpus directory")
|
|
49
|
+
|
|
50
|
+
p = sub.add_parser("search", help="Full-text search across the corpus")
|
|
51
|
+
p.add_argument("query", help="Search text (English or Japanese)")
|
|
52
|
+
p.add_argument("--corpus", default="corpus", help="Corpus directory (default: ./corpus)")
|
|
53
|
+
p.add_argument("--kind", choices=ALL_KINDS, help="Restrict search to one record kind")
|
|
54
|
+
p.add_argument("--top-k", type=int, default=5)
|
|
55
|
+
|
|
56
|
+
p = sub.add_parser("inspect", help="Print corpus statistics")
|
|
57
|
+
p.add_argument("--corpus", default="corpus", help="Corpus directory (default: ./corpus)")
|
|
58
|
+
|
|
59
|
+
p = sub.add_parser("tools", help="Print tool schemas for LLM integrations")
|
|
60
|
+
p.add_argument("format", choices=["openai", "anthropic", "json"])
|
|
61
|
+
|
|
62
|
+
p = sub.add_parser("export", help="Export fine-tuning datasets from a corpus")
|
|
63
|
+
p.add_argument("format", choices=["chat", "tool_trace", "cot"])
|
|
64
|
+
p.add_argument("--corpus", default="corpus", help="Corpus directory (default: ./corpus)")
|
|
65
|
+
p.add_argument("--out", required=True, help="Output JSONL path")
|
|
66
|
+
p.add_argument("--limit", type=int, help="Maximum number of records")
|
|
67
|
+
|
|
68
|
+
p = sub.add_parser("serve", help="Run the MCP server (stdio) over a corpus")
|
|
69
|
+
p.add_argument("--corpus", default="corpus", help="Corpus directory (default: ./corpus)")
|
|
70
|
+
return parser
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _run_ingest(args: argparse.Namespace) -> int:
|
|
74
|
+
ingest = INGEST_COMMANDS[args.command]
|
|
75
|
+
total = 0
|
|
76
|
+
for path in args.paths:
|
|
77
|
+
if not Path(path).exists():
|
|
78
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
chunks = ingest(path)
|
|
81
|
+
doc_id = derive_doc_id(path)
|
|
82
|
+
out_path = write_chunks(chunks, args.out, doc_id)
|
|
83
|
+
total += len(chunks)
|
|
84
|
+
print(f"{path}: {len(chunks)} records -> {out_path}")
|
|
85
|
+
print(f"Total: {total} records written to {args.out}")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _run_search(args: argparse.Namespace) -> int:
|
|
90
|
+
corpus = Corpus.load(args.corpus)
|
|
91
|
+
results = corpus.search(args.query, kinds=args.kind, top_k=args.top_k)
|
|
92
|
+
if not results:
|
|
93
|
+
print("No records matched.")
|
|
94
|
+
return 0
|
|
95
|
+
for rank, (chunk, score) in enumerate(results, start=1):
|
|
96
|
+
preview = chunk.text[:200].replace("\n", " ")
|
|
97
|
+
print(f"{rank}. [{chunk.kind}] score={score:.3f} id={chunk.chunk_id}")
|
|
98
|
+
print(f" {preview}")
|
|
99
|
+
print(f" source: {chunk.provenance}")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run_export(args: argparse.Namespace) -> int:
|
|
104
|
+
from .export.finetune import (
|
|
105
|
+
build_chat_records,
|
|
106
|
+
build_cot_records,
|
|
107
|
+
build_tool_trace_records,
|
|
108
|
+
write_jsonl,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
builders = {
|
|
112
|
+
"chat": build_chat_records,
|
|
113
|
+
"tool_trace": build_tool_trace_records,
|
|
114
|
+
"cot": build_cot_records,
|
|
115
|
+
}
|
|
116
|
+
corpus = Corpus.load(args.corpus)
|
|
117
|
+
records = builders[args.format](corpus, limit=args.limit)
|
|
118
|
+
out_path = write_jsonl(records, args.out)
|
|
119
|
+
print(f"Exported {len(records)} {args.format} records -> {out_path}")
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _run_serve(args: argparse.Namespace) -> int:
|
|
124
|
+
from .mcp_server import serve
|
|
125
|
+
|
|
126
|
+
serve(args.corpus)
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main(argv: list[str] | None = None) -> int:
|
|
131
|
+
parser = _build_parser()
|
|
132
|
+
args = parser.parse_args(argv)
|
|
133
|
+
try:
|
|
134
|
+
if args.command in INGEST_COMMANDS:
|
|
135
|
+
return _run_ingest(args)
|
|
136
|
+
if args.command == "search":
|
|
137
|
+
return _run_search(args)
|
|
138
|
+
if args.command == "inspect":
|
|
139
|
+
print(json.dumps(Corpus.load(args.corpus).stats(), indent=2, ensure_ascii=False))
|
|
140
|
+
return 0
|
|
141
|
+
if args.command == "tools":
|
|
142
|
+
schema = {
|
|
143
|
+
"openai": openai_tools,
|
|
144
|
+
"anthropic": anthropic_tools,
|
|
145
|
+
"json": json_schemas,
|
|
146
|
+
}[args.format]
|
|
147
|
+
print(json.dumps(schema(), indent=2, ensure_ascii=False))
|
|
148
|
+
return 0
|
|
149
|
+
if args.command == "export":
|
|
150
|
+
return _run_export(args)
|
|
151
|
+
if args.command == "serve":
|
|
152
|
+
return _run_serve(args)
|
|
153
|
+
parser.error(f"Unknown command: {args.command}")
|
|
154
|
+
return 2
|
|
155
|
+
except (FileNotFoundError, ToolError, RuntimeError, ValueError) as exc:
|
|
156
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
157
|
+
return 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
sys.exit(main())
|
purestrata/corpus.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Corpus storage: a directory of portable, auditable JSONL files.
|
|
2
|
+
|
|
3
|
+
A corpus is just ``*.strata.jsonl`` files, one JSON object per line per
|
|
4
|
+
chunk. No database, no server, no lock-in: companies can drop a corpus on a
|
|
5
|
+
file share, ship it to an air-gapped network, or commit small ones to git.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .search import SearchIndex
|
|
15
|
+
from .types import ALL_KINDS, Chunk
|
|
16
|
+
|
|
17
|
+
CORPUS_SUFFIX = ".strata.jsonl"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_chunks(chunks: list[Chunk], out_dir: str | Path, doc_id: str) -> Path:
|
|
21
|
+
"""Write one ``<doc_id>.strata.jsonl`` file for an ingested document."""
|
|
22
|
+
out_dir = Path(out_dir)
|
|
23
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
out_path = out_dir / f"{doc_id}{CORPUS_SUFFIX}"
|
|
25
|
+
with open(out_path, "w", encoding="utf-8") as handle:
|
|
26
|
+
for chunk in chunks:
|
|
27
|
+
handle.write(json.dumps(chunk.to_dict(), ensure_ascii=False))
|
|
28
|
+
handle.write("\n")
|
|
29
|
+
return out_path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Corpus:
|
|
33
|
+
"""An in-memory view of a directory of strata files, with search."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, chunks: list[Chunk]):
|
|
36
|
+
self.chunks = list(chunks)
|
|
37
|
+
self._index: SearchIndex | None = None
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def load(cls, corpus_dir: str | Path) -> Corpus:
|
|
41
|
+
"""Load every ``*.strata.jsonl`` file under ``corpus_dir``."""
|
|
42
|
+
corpus_dir = Path(corpus_dir)
|
|
43
|
+
if not corpus_dir.exists():
|
|
44
|
+
raise FileNotFoundError(f"Corpus directory not found: {corpus_dir}")
|
|
45
|
+
chunks: list[Chunk] = []
|
|
46
|
+
for path in sorted(corpus_dir.glob(f"*{CORPUS_SUFFIX}")):
|
|
47
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
48
|
+
if line.strip():
|
|
49
|
+
chunks.append(Chunk.from_dict(json.loads(line)))
|
|
50
|
+
return cls(chunks)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def index(self) -> SearchIndex:
|
|
54
|
+
if self._index is None:
|
|
55
|
+
self._index = SearchIndex(self.chunks)
|
|
56
|
+
return self._index
|
|
57
|
+
|
|
58
|
+
def stats(self) -> dict:
|
|
59
|
+
by_kind = Counter(chunk.kind for chunk in self.chunks)
|
|
60
|
+
by_doc = Counter(chunk.provenance.doc_id for chunk in self.chunks)
|
|
61
|
+
return {
|
|
62
|
+
"total_chunks": len(self.chunks),
|
|
63
|
+
"by_kind": {kind: by_kind[kind] for kind in ALL_KINDS if by_kind[kind]},
|
|
64
|
+
"documents": sorted(by_doc),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def search(self, query: str, kinds=None, top_k: int = 5) -> list[tuple[Chunk, float]]:
|
|
68
|
+
return self.index.search(query, kinds=kinds, top_k=top_k)
|
|
69
|
+
|
|
70
|
+
def get(self, chunk_id: str) -> Chunk | None:
|
|
71
|
+
for chunk in self.chunks:
|
|
72
|
+
if chunk.chunk_id == chunk_id:
|
|
73
|
+
return chunk
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def by_kind(self, *kinds: str) -> list[Chunk]:
|
|
77
|
+
wanted = set(kinds)
|
|
78
|
+
return [chunk for chunk in self.chunks if chunk.kind in wanted]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Fine-tuning dataset exporters."""
|
|
2
|
+
|
|
3
|
+
from .finetune import (
|
|
4
|
+
build_chat_records,
|
|
5
|
+
build_cot_records,
|
|
6
|
+
build_tool_trace_records,
|
|
7
|
+
write_jsonl,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"build_chat_records",
|
|
12
|
+
"build_cot_records",
|
|
13
|
+
"build_tool_trace_records",
|
|
14
|
+
"write_jsonl",
|
|
15
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Export a corpus into fine-tuning dataset formats.
|
|
2
|
+
|
|
3
|
+
The same corpus that serves live RAG queries is also the raw material for
|
|
4
|
+
training data. Three shapes are produced:
|
|
5
|
+
|
|
6
|
+
- ``chat``: system/user/assistant message JSONL, the format accepted by
|
|
7
|
+
OpenAI's and xAI's fine-tuning APIs.
|
|
8
|
+
- ``tool_trace``: message JSONL that includes a model-issued tool call,
|
|
9
|
+
the tool result, and the grounded final answer, for tool-use training.
|
|
10
|
+
- ``cot``: instruction / reasoning / output records with sources, a
|
|
11
|
+
scaffold for chain-of-thought refinement by domain experts.
|
|
12
|
+
|
|
13
|
+
Every record carries provenance, so dataset quality can be audited and
|
|
14
|
+
reproduced from the source documents.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ..corpus import Corpus
|
|
23
|
+
from ..tools.executor import ToolError, ToolExecutor
|
|
24
|
+
from ..types import FAILURE_CASE, PROCEDURE, SAFETY_NOTICE, Chunk
|
|
25
|
+
|
|
26
|
+
SYSTEM_PROMPT = (
|
|
27
|
+
"You are a manufacturing maintenance assistant. Answer using verified maintenance "
|
|
28
|
+
"and failure knowledge, and cite the source locator of every record you rely on."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sources(chunk: Chunk) -> list[dict]:
|
|
33
|
+
return [chunk.provenance.to_dict()]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _failure_question(chunk: Chunk) -> str:
|
|
37
|
+
meta = chunk.metadata
|
|
38
|
+
equipment = meta.get("equipment", "")
|
|
39
|
+
symptom = meta.get("symptom", "")
|
|
40
|
+
where = f" on equipment '{equipment}'" if equipment else ""
|
|
41
|
+
observed = f" We observe: {symptom.rstrip('.。')}." if symptom else ""
|
|
42
|
+
return (
|
|
43
|
+
f"We are troubleshooting a problem{where}.{observed}"
|
|
44
|
+
" What is the likely cause and the countermeasure?"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _failure_answer(chunk: Chunk) -> str:
|
|
49
|
+
meta = chunk.metadata
|
|
50
|
+
lines = []
|
|
51
|
+
part = meta.get("part_number")
|
|
52
|
+
if part:
|
|
53
|
+
lines.append(f"Involved part: {part}")
|
|
54
|
+
lines.append(f"Likely cause: {meta.get('cause') or 'not recorded'}")
|
|
55
|
+
lines.append(f"Countermeasure: {meta.get('countermeasure') or 'not recorded'}")
|
|
56
|
+
lines.append(f"Severity: {meta.get('severity', 'unspecified')}")
|
|
57
|
+
lines.append(f"Source: {chunk.provenance}")
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _procedure_qa(chunk: Chunk) -> dict | None:
|
|
62
|
+
meta = chunk.metadata
|
|
63
|
+
steps = meta.get("steps") or []
|
|
64
|
+
if not steps:
|
|
65
|
+
return None
|
|
66
|
+
equipment = meta.get("equipment")
|
|
67
|
+
scope = f" on {equipment}" if equipment else ""
|
|
68
|
+
question = f"List the steps of '{meta.get('title', 'the procedure')}'{scope}."
|
|
69
|
+
answer = "\n".join(f"{i}. {step}" for i, step in enumerate(steps, start=1))
|
|
70
|
+
answer += f"\nSource: {chunk.provenance}"
|
|
71
|
+
return {
|
|
72
|
+
"messages": [
|
|
73
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
74
|
+
{"role": "user", "content": question},
|
|
75
|
+
{"role": "assistant", "content": answer},
|
|
76
|
+
],
|
|
77
|
+
"sources": _sources(chunk),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _safety_qa(chunk: Chunk) -> dict | None:
|
|
82
|
+
if not chunk.text.strip():
|
|
83
|
+
return None
|
|
84
|
+
question = (
|
|
85
|
+
f"What safety precaution applies to '{chunk.metadata.get('section', 'this equipment')}'?"
|
|
86
|
+
)
|
|
87
|
+
answer = f"{chunk.text}\nSource: {chunk.provenance}"
|
|
88
|
+
return {
|
|
89
|
+
"messages": [
|
|
90
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
91
|
+
{"role": "user", "content": question},
|
|
92
|
+
{"role": "assistant", "content": answer},
|
|
93
|
+
],
|
|
94
|
+
"sources": _sources(chunk),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_chat_records(corpus: Corpus, limit: int | None = None) -> list[dict]:
|
|
99
|
+
"""Build chat-format fine-tuning records from failure cases, procedures, and notices."""
|
|
100
|
+
records: list[dict] = []
|
|
101
|
+
for chunk in corpus.chunks:
|
|
102
|
+
record = None
|
|
103
|
+
if chunk.kind == FAILURE_CASE:
|
|
104
|
+
record = {
|
|
105
|
+
"messages": [
|
|
106
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
107
|
+
{"role": "user", "content": _failure_question(chunk)},
|
|
108
|
+
{"role": "assistant", "content": _failure_answer(chunk)},
|
|
109
|
+
],
|
|
110
|
+
"sources": _sources(chunk),
|
|
111
|
+
}
|
|
112
|
+
elif chunk.kind == PROCEDURE:
|
|
113
|
+
record = _procedure_qa(chunk)
|
|
114
|
+
elif chunk.kind == SAFETY_NOTICE:
|
|
115
|
+
record = _safety_qa(chunk)
|
|
116
|
+
if record is not None:
|
|
117
|
+
records.append(record)
|
|
118
|
+
return records[:limit] if limit else records
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_tool_trace_records(corpus: Corpus, limit: int | None = None) -> list[dict]:
|
|
122
|
+
"""Build tool-use fine-tuning records from failure cases.
|
|
123
|
+
|
|
124
|
+
Each record shows the model choosing ``search_failure_cases`` with a
|
|
125
|
+
symptom-driven query, receiving provenance-tagged results, and
|
|
126
|
+
answering from them.
|
|
127
|
+
"""
|
|
128
|
+
executor = ToolExecutor(corpus)
|
|
129
|
+
records: list[dict] = []
|
|
130
|
+
for i, chunk in enumerate(corpus.by_kind(FAILURE_CASE), start=1):
|
|
131
|
+
meta = chunk.metadata
|
|
132
|
+
symptom = meta.get("symptom") or meta.get("cause") or chunk.text[:120]
|
|
133
|
+
question = _failure_question(chunk)
|
|
134
|
+
call_args = {"query": symptom, "top_k": 3}
|
|
135
|
+
try:
|
|
136
|
+
tool_result = executor.execute("search_failure_cases", call_args)
|
|
137
|
+
except ToolError:
|
|
138
|
+
continue
|
|
139
|
+
call_id = f"call_{i:04d}"
|
|
140
|
+
final = _failure_answer(chunk)
|
|
141
|
+
record = {
|
|
142
|
+
"messages": [
|
|
143
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
144
|
+
{"role": "user", "content": question},
|
|
145
|
+
{
|
|
146
|
+
"role": "assistant",
|
|
147
|
+
"content": None,
|
|
148
|
+
"tool_calls": [
|
|
149
|
+
{
|
|
150
|
+
"id": call_id,
|
|
151
|
+
"type": "function",
|
|
152
|
+
"function": {
|
|
153
|
+
"name": "search_failure_cases",
|
|
154
|
+
"arguments": json.dumps(call_args, ensure_ascii=False),
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"role": "tool",
|
|
161
|
+
"tool_call_id": call_id,
|
|
162
|
+
"content": json.dumps(tool_result, ensure_ascii=False, default=str),
|
|
163
|
+
},
|
|
164
|
+
{"role": "assistant", "content": final},
|
|
165
|
+
],
|
|
166
|
+
"sources": _sources(chunk),
|
|
167
|
+
}
|
|
168
|
+
records.append(record)
|
|
169
|
+
return records[:limit] if limit else records
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_cot_records(corpus: Corpus, limit: int | None = None) -> list[dict]:
|
|
173
|
+
"""Build chain-of-thought records from failure cases.
|
|
174
|
+
|
|
175
|
+
The reasoning trace is a transparent scaffold over structured fields,
|
|
176
|
+
designed to be reviewed and sharpened by maintenance engineers before
|
|
177
|
+
training. That expert-in-the-loop pass is where dataset quality comes
|
|
178
|
+
from; these records make the work auditable.
|
|
179
|
+
"""
|
|
180
|
+
records: list[dict] = []
|
|
181
|
+
for chunk in corpus.by_kind(FAILURE_CASE):
|
|
182
|
+
meta = chunk.metadata
|
|
183
|
+
cause = meta.get("cause") or "not recorded"
|
|
184
|
+
countermeasure = meta.get("countermeasure") or "not recorded"
|
|
185
|
+
reasoning = (
|
|
186
|
+
f"The report concerns equipment '{meta.get('equipment', 'unspecified')}'. "
|
|
187
|
+
f"The observed symptom is: {meta.get('symptom', 'unspecified')}. "
|
|
188
|
+
f"Failure case {meta.get('case_id', chunk.chunk_id)} in the verified history "
|
|
189
|
+
f"matches this equipment and symptom. Its recorded root cause was: {cause}. "
|
|
190
|
+
f"The applied countermeasure was: {countermeasure}. "
|
|
191
|
+
"Since the symptom matches the recorded case, the same root cause is the most "
|
|
192
|
+
"likely explanation, and the same countermeasure applies before further "
|
|
193
|
+
"investigation."
|
|
194
|
+
)
|
|
195
|
+
records.append(
|
|
196
|
+
{
|
|
197
|
+
"instruction": _failure_question(chunk),
|
|
198
|
+
"reasoning": reasoning,
|
|
199
|
+
"output": _failure_answer(chunk),
|
|
200
|
+
"sources": _sources(chunk),
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
return records[:limit] if limit else records
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def write_jsonl(records: list[dict], out_path: str | Path) -> Path:
|
|
207
|
+
"""Write records as JSONL and return the path."""
|
|
208
|
+
out_path = Path(out_path)
|
|
209
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
with open(out_path, "w", encoding="utf-8") as handle:
|
|
211
|
+
for record in records:
|
|
212
|
+
handle.write(json.dumps(record, ensure_ascii=False, default=str))
|
|
213
|
+
handle.write("\n")
|
|
214
|
+
return out_path
|