feat(v2): 实现SQLite对公租赁经验加工与卡片预审轨迹闭环
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""BR-64: managed, immutable standard Skill packages; scripts are never executed."""
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
import yaml
|
||||
from services import assistant_store as store, component_registry as registry, knowledge_cards
|
||||
from services.assistant_materials import FIELDS
|
||||
from services.lease_case_library import LibraryError
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1] / 'xingbang_data' / 'skill_packages'
|
||||
MAX_TOTAL = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def validate_files(files):
|
||||
if not 1 <= len(files) <= 100:
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', 'Skills 文件夹需要 1–100 个文件')
|
||||
decoded, total = {}, 0
|
||||
roots = set()
|
||||
for item in files:
|
||||
path = str(item['path']).replace('\\', '/')
|
||||
parts = PurePosixPath(path).parts
|
||||
if not parts or len(parts) < 2 or path.startswith('/') or any(
|
||||
p in ('.', '..') or ':' in p or not p.strip() or p.endswith((' ', '.'))
|
||||
or re.match(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)', p, re.I)
|
||||
for p in path.split('/')):
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', '文件路径不安全,请选择一个标准 Skills 文件夹')
|
||||
roots.add(parts[0])
|
||||
relative = '/'.join(parts[1:])
|
||||
if relative in decoded:
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', '文件夹存在重复路径')
|
||||
try:
|
||||
raw = base64.b64decode(item['content_base64'], validate=True)
|
||||
except (ValueError, TypeError):
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', '文件内容编码无效') from None
|
||||
total += len(raw)
|
||||
if len(raw) > 2 * 1024 * 1024 or total > MAX_TOTAL:
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', '文件超过单文件 2MiB 或文件夹 10MiB 上限')
|
||||
decoded[relative] = raw
|
||||
if len(roots) != 1 or 'SKILL.md' not in decoded:
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', '请选择单个文件夹,根目录必须有 SKILL.md')
|
||||
try:
|
||||
text = decoded['SKILL.md'].decode('utf-8-sig')
|
||||
if len(decoded['SKILL.md']) > 65536:
|
||||
raise ValueError()
|
||||
match = re.match(r'\A---\s*\r?\n(.*?)\r?\n---\s*\r?\n([\s\S]+)\Z', text, re.S)
|
||||
meta = yaml.safe_load(match.group(1)) if match else None
|
||||
if not isinstance(meta, dict):
|
||||
raise ValueError()
|
||||
name, description = meta.get('name'), meta.get('description')
|
||||
if not isinstance(name, str) or not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', name) or len(name) > 64:
|
||||
raise ValueError()
|
||||
if not isinstance(description, str) or not description.strip() or len(description) > 1024 or name != next(iter(roots)):
|
||||
raise ValueError()
|
||||
except (UnicodeError, ValueError, yaml.YAMLError):
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', 'SKILL.md 需要标准 YAML name/description,name 与文件夹名一致') from None
|
||||
return name, description, text, decoded
|
||||
|
||||
|
||||
def write_bundle(db, component_id, version, name, files, root=None):
|
||||
root = Path(root or ROOT).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
package_id = store.uid('package')
|
||||
package_root = root / package_id
|
||||
folder = package_root / name
|
||||
if package_root.parent != root:
|
||||
raise ValueError('不安全的托管路径')
|
||||
folder.mkdir(parents=True)
|
||||
try:
|
||||
manifest = []
|
||||
for relative, raw in sorted(files.items()):
|
||||
target = folder.joinpath(*PurePosixPath(relative).parts)
|
||||
if not target.resolve().is_relative_to(folder.resolve()):
|
||||
raise ValueError('不安全的文件路径')
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(raw)
|
||||
manifest.append({'path': relative, 'size': len(raw), 'sha256': hashlib.sha256(raw).hexdigest()})
|
||||
body = {'id': package_id, 'component_id': component_id, 'version': version, 'name': name,
|
||||
'manifest': manifest, 'content_hash': store.digest(manifest), 'created_at': store.now()}
|
||||
store.put(db, 'skill_package', body, immutable=True)
|
||||
return body
|
||||
except Exception:
|
||||
shutil.rmtree(package_root) # Only the fresh UUID folder created above.
|
||||
raise
|
||||
|
||||
|
||||
def import_package(db, project, actor, files, required_field=None, root=None):
|
||||
name, description, text, decoded = validate_files(files)
|
||||
if required_field and required_field not in set(FIELDS.values()):
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', '核查字段不在允许的标准字段中')
|
||||
for kind in ('component_draft', 'component_version'):
|
||||
if any(row.get('package_name') == name and row.get('project_id') == project['id'] for row in store.listing(db, kind)):
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', '该项目已导入同名 Skill,请修订版本,不覆盖原文件夹')
|
||||
body = {'id': store.uid('skill.folder'), 'kind': 'skill', 'name': description[:80],
|
||||
'package_name': name, 'scope': project['data_domain'], 'version': '1.0.0',
|
||||
'engine': 'required' if required_field else 'model', 'inputs': [required_field] if required_field else [],
|
||||
'code': 'folder.' + name, 'title': description[:120], 'instructions': text,
|
||||
'description': description, 'import_method': 'folder', 'allowed_tools': [] if required_field else sorted(registry.TOOLS),
|
||||
'dimensions': [{'id': 'folder.' + name, 'name': description[:20], 'label': description[:20],
|
||||
'scale_id': 'stage', 'fields': [required_field]}] if required_field else []}
|
||||
draft = knowledge_cards.propose(db, body, actor, project['id'], origin='manual')
|
||||
package = write_bundle(db, draft['component_id'], draft['version'], name, decoded, root)
|
||||
store.event(db, project['id'], 'skill.folder_import', actor,
|
||||
{'draft_id': draft['id'], 'package_id': package['id'], 'content_hash': package['content_hash']})
|
||||
return dict(draft, package=package)
|
||||
|
||||
|
||||
def standard_package(db, component, root=None):
|
||||
component_id = component.get('component_id') or component['id']
|
||||
version = component['version']
|
||||
packages = [p for p in store.listing(db, 'skill_package') if p['component_id'] == component_id and p['version'] == version]
|
||||
if packages:
|
||||
return packages[0]
|
||||
name = re.sub('[^a-z0-9-]', '-', component_id.lower()).strip('-')[:64]
|
||||
description = str(component.get('description') or component['name'])[:1024]
|
||||
text = '---\n' + yaml.safe_dump({'name': name, 'description': description}, allow_unicode=True, sort_keys=False) + '---\n\n'
|
||||
text += '# ' + component['name'] + '\n\n' + str(component.get('instructions') or '按 references/executor.json 中的受控执行器、适用字段与规则进行核查。')
|
||||
text += '\n\n## 执行边界\n只读核查;不执行脚本,不扩大工具权限,不替代正式评审会。\n'
|
||||
return write_bundle(db, component_id, version, name,
|
||||
{'SKILL.md': text.encode('utf-8'), 'references/executor.json': json.dumps(component, ensure_ascii=False, indent=2).encode('utf-8')}, root)
|
||||
|
||||
|
||||
def content(db, component, root=None):
|
||||
package = standard_package(db, component, root)
|
||||
folder = Path(root or ROOT).resolve() / package['id'] / package['name']
|
||||
if folder.is_symlink() or not folder.resolve().is_relative_to(Path(root or ROOT).resolve()):
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', '托管 Skills 文件夹路径无效')
|
||||
files = []
|
||||
for entry in package['manifest']:
|
||||
path = folder / entry['path']
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(folder.resolve()):
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', '托管文件路径无效')
|
||||
raw = path.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != entry['sha256']:
|
||||
raise LibraryError('SKILL_PACKAGE_INVALID', 'Skills 文件已改变,请通过新版本修订')
|
||||
try:
|
||||
text = raw.decode('utf-8-sig')
|
||||
except UnicodeError:
|
||||
text = None
|
||||
files.append({**entry, 'text': text})
|
||||
return {**package, 'files': files}
|
||||
|
||||
|
||||
def archive(db, component, root=None):
|
||||
package = content(db, component, root)
|
||||
folder = Path(root or ROOT).resolve() / package['id'] / package['name']
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w', compression=zipfile.ZIP_DEFLATED) as zipped:
|
||||
for entry in package['manifest']:
|
||||
zipped.writestr(package['name'] + '/' + entry['path'], (folder / entry['path']).read_bytes())
|
||||
return package['name'], stream.getvalue()
|
||||
|
||||
|
||||
def open_folder(db, component, root=None):
|
||||
package = content(db, component, root)
|
||||
if os.name != 'nt':
|
||||
raise LibraryError('SKILL_FOLDER_UNAVAILABLE', '当前部署不支持打开本机文件夹,请下载标准包')
|
||||
os.startfile(str(Path(root or ROOT).resolve() / package['id'] / package['name']))
|
||||
return {'opened': True, 'package_id': package['id']}
|
||||
Reference in New Issue
Block a user