union_kb_ingest 1.0.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 +100 -0
- package/app_config.py +84 -0
- package/approved/.gitkeep +1 -0
- package/bin/union_kb_ingest +5 -0
- package/config/config.yaml +8 -0
- package/drafts/.gitkeep +1 -0
- package/ingest.py +157 -0
- package/input/.gitkeep +1 -0
- package/input/pdf/.gitkeep +1 -0
- package/input/word/.gitkeep +1 -0
- package/normalizer.py +413 -0
- package/package.json +27 -0
- package/parsed/.gitkeep +1 -0
- package/parser.py +287 -0
- package/prompts/generate_kb_items.md +27 -0
- package/prompts//350/201/224/345/220/210/350/277/220/347/273/264/347/237/245/350/257/206/345/272/223/345/273/272/347/253/213/350/247/204/350/214/203.md +272 -0
- package/requirements.txt +9 -0
- package/schemas.py +85 -0
- package/splitter.py +127 -0
- package/validator.py +72 -0
- package/writer.py +33 -0
package/normalizer.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List
|
|
10
|
+
|
|
11
|
+
from app_config import get_llm_config
|
|
12
|
+
from schemas import DOC_TYPES, KnowledgeItem, ParsedBlock
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_DOMAIN = "联合运维"
|
|
16
|
+
DEFAULT_OWNER = "联合运维知识库"
|
|
17
|
+
CURRENT_DIR = Path(__file__).resolve().parent
|
|
18
|
+
KB_SPEC_PATH = CURRENT_DIR / "prompts" / "联合运维知识库建立规范.md"
|
|
19
|
+
LLM_MAX_RETRIES = 10
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_block(block: ParsedBlock, status: str = "draft") -> List[KnowledgeItem]:
|
|
23
|
+
if get_llm_config().enabled:
|
|
24
|
+
items = _normalize_with_llm(block, status=status)
|
|
25
|
+
if items:
|
|
26
|
+
return items
|
|
27
|
+
_abort_llm("model returned no valid knowledge items", block)
|
|
28
|
+
return [_normalize_heuristically(block, status=status)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_with_llm(block: ParsedBlock, status: str) -> List[KnowledgeItem]:
|
|
32
|
+
config = get_llm_config()
|
|
33
|
+
if not (config.base_url and config.api_key and config.model):
|
|
34
|
+
_abort_llm("missing base_url, api_key, or model", block)
|
|
35
|
+
|
|
36
|
+
prompt = _build_prompt(block, status)
|
|
37
|
+
started_at = time.monotonic()
|
|
38
|
+
print(
|
|
39
|
+
"llm start: "
|
|
40
|
+
f"doc={block.source_doc} section={block.source_section} "
|
|
41
|
+
f"chars={len(block.content)} model={config.model}"
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
client_cls = _get_zhipu_client_class()
|
|
45
|
+
except ImportError as exc:
|
|
46
|
+
print(f"llm error: cannot load ZhipuAI SDK ({exc})")
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
client = client_cls(api_key=config.api_key, base_url=config.base_url)
|
|
50
|
+
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
|
51
|
+
try:
|
|
52
|
+
print(f"llm request: base_url={config.base_url} attempt={attempt}/{LLM_MAX_RETRIES}")
|
|
53
|
+
response = client.chat.completions.create(
|
|
54
|
+
model=config.model,
|
|
55
|
+
messages=[
|
|
56
|
+
{"role": "system", "content": "你是严谨的运维知识库整理助手,只能依据输入原文生成结构化知识条目。"},
|
|
57
|
+
{"role": "user", "content": prompt},
|
|
58
|
+
],
|
|
59
|
+
max_tokens=config.max_tokens,
|
|
60
|
+
temperature=config.temperature,
|
|
61
|
+
response_format={"type": "json_object"},
|
|
62
|
+
thinking={"type": "disabled"},
|
|
63
|
+
)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
elapsed = time.monotonic() - started_at
|
|
66
|
+
print(f"llm error: {type(exc).__name__} attempt={attempt}/{LLM_MAX_RETRIES} after {elapsed:.1f}s")
|
|
67
|
+
if attempt >= LLM_MAX_RETRIES:
|
|
68
|
+
_abort_llm(f"request failed after {LLM_MAX_RETRIES} attempts: {type(exc).__name__}", block)
|
|
69
|
+
time.sleep(min(2 ** (attempt - 1), 30))
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
content = _extract_response_content(response)
|
|
73
|
+
elapsed = time.monotonic() - started_at
|
|
74
|
+
print(f"llm response: {len(content)} chars in {elapsed:.1f}s attempt={attempt}/{LLM_MAX_RETRIES}")
|
|
75
|
+
if not content.strip():
|
|
76
|
+
reasoning = _extract_reasoning_content(response)
|
|
77
|
+
print(
|
|
78
|
+
"llm parse failed: empty response content "
|
|
79
|
+
f"finish_reason={_finish_reason(response)} reasoning_chars={len(reasoning)} "
|
|
80
|
+
f"response={_response_debug(response)}"
|
|
81
|
+
)
|
|
82
|
+
if attempt >= LLM_MAX_RETRIES:
|
|
83
|
+
_abort_llm("empty response content after 10 attempts", block)
|
|
84
|
+
time.sleep(min(2 ** (attempt - 1), 30))
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
parsed = _extract_json(content)
|
|
88
|
+
if not parsed:
|
|
89
|
+
print(f"llm parse failed: response is not valid JSON preview={_preview(content)}")
|
|
90
|
+
if attempt >= LLM_MAX_RETRIES:
|
|
91
|
+
_abort_llm("response is not valid JSON after 10 attempts", block)
|
|
92
|
+
time.sleep(min(2 ** (attempt - 1), 30))
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
raw_items = parsed.get("items") if isinstance(parsed, dict) else parsed
|
|
96
|
+
if not isinstance(raw_items, list):
|
|
97
|
+
print("llm parse failed: JSON does not contain an items list")
|
|
98
|
+
if attempt >= LLM_MAX_RETRIES:
|
|
99
|
+
_abort_llm("JSON does not contain an items list after 10 attempts", block)
|
|
100
|
+
time.sleep(min(2 ** (attempt - 1), 30))
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
items: List[KnowledgeItem] = []
|
|
104
|
+
for idx, raw in enumerate(raw_items, start=1):
|
|
105
|
+
if not isinstance(raw, dict):
|
|
106
|
+
continue
|
|
107
|
+
items.append(_item_from_dict(raw, block, idx, status))
|
|
108
|
+
print(f"llm done: generated_items={len(items)} attempt={attempt}/{LLM_MAX_RETRIES}")
|
|
109
|
+
if items:
|
|
110
|
+
return items
|
|
111
|
+
if attempt >= LLM_MAX_RETRIES:
|
|
112
|
+
_abort_llm("items list contained no valid objects after 10 attempts", block)
|
|
113
|
+
time.sleep(min(2 ** (attempt - 1), 30))
|
|
114
|
+
|
|
115
|
+
_abort_llm("model call failed", block)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _get_zhipu_client_class():
|
|
119
|
+
try:
|
|
120
|
+
from zai import ZhipuAiClient
|
|
121
|
+
|
|
122
|
+
return ZhipuAiClient
|
|
123
|
+
except (ImportError, AttributeError):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
from zhipuai import ZhipuAI
|
|
127
|
+
|
|
128
|
+
return ZhipuAI
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _extract_response_content(response) -> str:
|
|
132
|
+
if isinstance(response, dict):
|
|
133
|
+
choices = response.get("choices") or []
|
|
134
|
+
if not choices:
|
|
135
|
+
return ""
|
|
136
|
+
message = choices[0].get("message") if isinstance(choices[0], dict) else None
|
|
137
|
+
return str((message or {}).get("content") or "")
|
|
138
|
+
|
|
139
|
+
choices = getattr(response, "choices", None) or []
|
|
140
|
+
if not choices:
|
|
141
|
+
return ""
|
|
142
|
+
message = getattr(choices[0], "message", None)
|
|
143
|
+
if message is None and isinstance(choices[0], dict):
|
|
144
|
+
message = choices[0].get("message")
|
|
145
|
+
if isinstance(message, dict):
|
|
146
|
+
return str(message.get("content") or "")
|
|
147
|
+
return str(getattr(message, "content", "") or "")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _extract_reasoning_content(response) -> str:
|
|
151
|
+
message = _first_message(response)
|
|
152
|
+
if isinstance(message, dict):
|
|
153
|
+
return str(message.get("reasoning_content") or "")
|
|
154
|
+
return str(getattr(message, "reasoning_content", "") or "")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _finish_reason(response) -> str:
|
|
158
|
+
choice = _first_choice(response)
|
|
159
|
+
if isinstance(choice, dict):
|
|
160
|
+
return str(choice.get("finish_reason") or "")
|
|
161
|
+
return str(getattr(choice, "finish_reason", "") or "")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _first_message(response):
|
|
165
|
+
choice = _first_choice(response)
|
|
166
|
+
if isinstance(choice, dict):
|
|
167
|
+
return choice.get("message")
|
|
168
|
+
return getattr(choice, "message", None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _first_choice(response):
|
|
172
|
+
if isinstance(response, dict):
|
|
173
|
+
choices = response.get("choices") or []
|
|
174
|
+
else:
|
|
175
|
+
choices = getattr(response, "choices", None) or []
|
|
176
|
+
return choices[0] if choices else None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _response_debug(response) -> str:
|
|
180
|
+
text = repr(response)
|
|
181
|
+
if len(text) > 300:
|
|
182
|
+
text = text[:300] + "..."
|
|
183
|
+
return _preview(text, limit=300)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _abort_llm(message: str, block: ParsedBlock) -> None:
|
|
187
|
+
print(
|
|
188
|
+
"ALERT: llm draft failed; aborting. "
|
|
189
|
+
f"reason={message} doc={block.source_doc} section={block.source_section}"
|
|
190
|
+
)
|
|
191
|
+
raise SystemExit(1)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _build_prompt(block: ParsedBlock, status: str) -> str:
|
|
195
|
+
spec = _read_kb_spec()
|
|
196
|
+
return f"""
|
|
197
|
+
请将以下原始文档片段整理为标准知识库条目。
|
|
198
|
+
|
|
199
|
+
要求:
|
|
200
|
+
1. 严格参照《联合运维知识库建立规范》的元数据字段、正文 1-7 节结构、内容切分原则和质量校验要求生成。
|
|
201
|
+
2. 只依据原文理解业务场景、业务模块、角色、标签、风险等级和处置策略,不要依据示例或常见关键词进行套写。
|
|
202
|
+
3. 如果一个片段包含多个独立场景、规则、指标、处置策略,请拆成多个 items。
|
|
203
|
+
4. 每个 item 必须可独立检索、独立回答,颗粒度控制在 800 到 1500 中文字符左右;复杂表格可适当放宽。
|
|
204
|
+
5. 不要编造来源、阈值、角色、日期、版本;原文没有的信息留空、空数组或使用规范允许的通用值。
|
|
205
|
+
6. 涉及表格、阈值、比较符、单位、持续时间、笔数、适用对象时必须保留原始逻辑。
|
|
206
|
+
7. 输出严格 JSON 对象,不要 Markdown 代码围栏,不要解释文字,不要在 JSON 前后添加任何内容。
|
|
207
|
+
8. status 固定为 "{status}"。
|
|
208
|
+
|
|
209
|
+
doc_type 只能取:
|
|
210
|
+
{", ".join(sorted(DOC_TYPES))}
|
|
211
|
+
|
|
212
|
+
唯一允许的 JSON 输出格式:
|
|
213
|
+
{{
|
|
214
|
+
"items": [
|
|
215
|
+
{{
|
|
216
|
+
"title": "",
|
|
217
|
+
"doc_type": "scenario",
|
|
218
|
+
"business_modules": [],
|
|
219
|
+
"source_version": "",
|
|
220
|
+
"risk_level": "low|medium|high|critical",
|
|
221
|
+
"applicable_roles": [],
|
|
222
|
+
"tags": [],
|
|
223
|
+
"body": "Markdown 正文,必须严格包含规范要求的 # 标题 和 ## 1. 适用范围 到 ## 7. 来源依据",
|
|
224
|
+
"split_reason": "为什么这是独立条目"
|
|
225
|
+
}}
|
|
226
|
+
]
|
|
227
|
+
}}
|
|
228
|
+
|
|
229
|
+
《联合运维知识库建立规范》:
|
|
230
|
+
{spec}
|
|
231
|
+
|
|
232
|
+
来源文档:{block.source_doc}
|
|
233
|
+
来源章节:{block.source_section}
|
|
234
|
+
来源页码:{",".join(map(str, block.pages)) if block.pages else ""}
|
|
235
|
+
|
|
236
|
+
原文:
|
|
237
|
+
{block.content[:12000]}
|
|
238
|
+
""".strip()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@lru_cache(maxsize=1)
|
|
242
|
+
def _read_kb_spec() -> str:
|
|
243
|
+
try:
|
|
244
|
+
return KB_SPEC_PATH.read_text(encoding="utf-8").strip()
|
|
245
|
+
except FileNotFoundError:
|
|
246
|
+
return ""
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _extract_json(text: str):
|
|
250
|
+
text = text.strip()
|
|
251
|
+
text = _strip_code_fence(text)
|
|
252
|
+
try:
|
|
253
|
+
return json.loads(text)
|
|
254
|
+
except json.JSONDecodeError:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
for candidate in _json_candidates(text):
|
|
258
|
+
try:
|
|
259
|
+
return json.loads(candidate)
|
|
260
|
+
except json.JSONDecodeError:
|
|
261
|
+
continue
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _strip_code_fence(text: str) -> str:
|
|
266
|
+
text = text.strip()
|
|
267
|
+
fence = re.match(r"^```(?:json|JSON)?\s*(.*?)\s*```$", text, re.S)
|
|
268
|
+
return fence.group(1).strip() if fence else text
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _json_candidates(text: str) -> List[str]:
|
|
272
|
+
candidates: List[str] = []
|
|
273
|
+
for opener, closer in (("{", "}"), ("[", "]")):
|
|
274
|
+
start = text.find(opener)
|
|
275
|
+
while start != -1:
|
|
276
|
+
end = text.rfind(closer)
|
|
277
|
+
while end > start:
|
|
278
|
+
candidates.append(text[start:end + 1])
|
|
279
|
+
end = text.rfind(closer, 0, end)
|
|
280
|
+
start = text.find(opener, start + 1)
|
|
281
|
+
return candidates
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _preview(text: str, limit: int = 500) -> str:
|
|
285
|
+
return re.sub(r"\s+", " ", text).strip()[:limit]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _normalize_heuristically(block: ParsedBlock, status: str) -> KnowledgeItem:
|
|
289
|
+
title = _guess_title(block)
|
|
290
|
+
doc_type = "biz"
|
|
291
|
+
body = _build_body(title, block.content)
|
|
292
|
+
return KnowledgeItem(
|
|
293
|
+
kb_id=_kb_id(doc_type, title, block.source_doc, 1),
|
|
294
|
+
title=title,
|
|
295
|
+
doc_type=doc_type,
|
|
296
|
+
domain=DEFAULT_DOMAIN,
|
|
297
|
+
business_modules=[],
|
|
298
|
+
source_doc=block.source_doc,
|
|
299
|
+
source_version=_guess_version(block.source_doc + " " + block.content),
|
|
300
|
+
source_section=block.source_section,
|
|
301
|
+
effective_date="",
|
|
302
|
+
owner=DEFAULT_OWNER,
|
|
303
|
+
confidentiality="内部",
|
|
304
|
+
risk_level="low",
|
|
305
|
+
applicable_roles=[],
|
|
306
|
+
tags=[],
|
|
307
|
+
status=status,
|
|
308
|
+
review_status="pending",
|
|
309
|
+
source_trace=_source_trace(block),
|
|
310
|
+
body=body,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _item_from_dict(raw: Dict, block: ParsedBlock, idx: int, status: str) -> KnowledgeItem:
|
|
315
|
+
title = str(raw.get("title") or _guess_title(block)).strip()
|
|
316
|
+
doc_type = str(raw.get("doc_type") or "biz").strip()
|
|
317
|
+
if doc_type not in DOC_TYPES:
|
|
318
|
+
doc_type = "biz"
|
|
319
|
+
body = str(raw.get("body") or _build_body(title, block.content)).strip()
|
|
320
|
+
if not body.startswith("# "):
|
|
321
|
+
body = f"# {title}\n\n{body}"
|
|
322
|
+
return KnowledgeItem(
|
|
323
|
+
kb_id=_kb_id(doc_type, title, block.source_doc, idx),
|
|
324
|
+
title=title,
|
|
325
|
+
doc_type=doc_type,
|
|
326
|
+
domain=DEFAULT_DOMAIN,
|
|
327
|
+
business_modules=_as_list(raw.get("business_modules")),
|
|
328
|
+
source_doc=block.source_doc,
|
|
329
|
+
source_version=str(raw.get("source_version") or _guess_version(block.source_doc + " " + block.content)),
|
|
330
|
+
source_section=block.source_section,
|
|
331
|
+
effective_date="",
|
|
332
|
+
owner=DEFAULT_OWNER,
|
|
333
|
+
confidentiality="内部",
|
|
334
|
+
risk_level=_normalize_risk_level(raw.get("risk_level")),
|
|
335
|
+
applicable_roles=_as_list(raw.get("applicable_roles")),
|
|
336
|
+
tags=_as_list(raw.get("tags")),
|
|
337
|
+
status=status,
|
|
338
|
+
review_status="pending",
|
|
339
|
+
source_trace=_source_trace(block),
|
|
340
|
+
body=body,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _as_list(value) -> List[str]:
|
|
345
|
+
if isinstance(value, list):
|
|
346
|
+
return [str(v).strip() for v in value if str(v).strip()]
|
|
347
|
+
if isinstance(value, str) and value.strip():
|
|
348
|
+
return [v.strip() for v in re.split(r"[,,、]", value) if v.strip()]
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _guess_title(block: ParsedBlock) -> str:
|
|
353
|
+
for line in block.content.splitlines():
|
|
354
|
+
match = re.match(r"^#{1,4}\s+(.+)$", line.strip())
|
|
355
|
+
if match:
|
|
356
|
+
return match.group(1).strip()[:80]
|
|
357
|
+
return block.source_section[:80] or "待整理知识条目"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _normalize_risk_level(value) -> str:
|
|
361
|
+
risk_level = str(value or "low").strip().lower()
|
|
362
|
+
return risk_level if risk_level in {"low", "medium", "high", "critical"} else "low"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _guess_version(text: str) -> str:
|
|
366
|
+
match = re.search(r"[vV]\s*(\d+(?:\.\d+)*)", text)
|
|
367
|
+
return f"V{match.group(1)}" if match else ""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _build_body(title: str, source_content: str) -> str:
|
|
371
|
+
content = source_content.strip()
|
|
372
|
+
content = re.sub(r"^#{1,4}\s+.+\n?", "", content, count=1).strip()
|
|
373
|
+
return f"""# {title}
|
|
374
|
+
|
|
375
|
+
## 1. 适用范围
|
|
376
|
+
|
|
377
|
+
待人工审核确认。以下内容基于来源文档片段整理。
|
|
378
|
+
|
|
379
|
+
## 2. 规则与条件
|
|
380
|
+
|
|
381
|
+
{content}
|
|
382
|
+
|
|
383
|
+
## 3. 处置策略
|
|
384
|
+
|
|
385
|
+
待人工审核补充或确认。
|
|
386
|
+
|
|
387
|
+
## 4. 关联指标
|
|
388
|
+
|
|
389
|
+
待人工审核补充或确认。
|
|
390
|
+
|
|
391
|
+
## 5. 关联函数
|
|
392
|
+
|
|
393
|
+
暂无。
|
|
394
|
+
|
|
395
|
+
## 6. 检索提示
|
|
396
|
+
|
|
397
|
+
1. “{title}的规则是什么?”
|
|
398
|
+
2. “{title}适用于哪些场景?”
|
|
399
|
+
|
|
400
|
+
## 7. 来源依据
|
|
401
|
+
|
|
402
|
+
基于来源章节归纳,需人工复核原文一致性。
|
|
403
|
+
""".strip()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _kb_id(doc_type: str, title: str, source_doc: str, idx: int) -> str:
|
|
407
|
+
digest = hashlib.md5(f"{source_doc}:{title}:{idx}".encode("utf-8")).hexdigest()[:10]
|
|
408
|
+
return f"{doc_type}-offline-{digest}-v1"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _source_trace(block: ParsedBlock) -> str:
|
|
412
|
+
pages = ",".join(map(str, block.pages)) if block.pages else ""
|
|
413
|
+
return f"section={block.source_section}; pages={pages}".strip("; ")
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "union_kb_ingest",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Offline knowledge-base ingest helper for PDF, Word, Markdown and TXT documents.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"union_kb_ingest": "bin/union_kb_ingest"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"README.md",
|
|
10
|
+
"requirements.txt",
|
|
11
|
+
"bin/union_kb_ingest",
|
|
12
|
+
"*.py",
|
|
13
|
+
"config/config.yaml",
|
|
14
|
+
"prompts/",
|
|
15
|
+
"input/.gitkeep",
|
|
16
|
+
"input/pdf/.gitkeep",
|
|
17
|
+
"input/word/.gitkeep",
|
|
18
|
+
"parsed/.gitkeep",
|
|
19
|
+
"drafts/.gitkeep",
|
|
20
|
+
"approved/.gitkeep"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"pack:check": "npm pack --dry-run"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED",
|
|
26
|
+
"private": false
|
|
27
|
+
}
|
package/parsed/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|