mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
* feat(skills): reserve document provider slots Separate document provider slots from protected core skill names. Keep existing same-name shadows declarations compatible. Normalize legacy document OFF markers in JSON and database stores. Persist normalization under the existing lock or transaction so an explicit re-enable cannot be overwritten by a stale read. Cover persistence, rollback, ownership and concurrent replacement. Refresh the contracts inventory from the passing Ginkgo report. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: nib:gpt-6.1-sol * feat(skills): gate Sandbox document procedures Resolve document providers against the selected isolated command backend. Keep Office guidance available without command execution and preserve explicit provider pins when their backend is unavailable. Reuse authorized chat access during tool construction to avoid reentering the session lock. Recheck access and capabilities when loading guidance. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: nib:gpt-6.1-sol * fix(skills): resolve saved-agent child command grants Describe saved-agent child commands from their current agent scope rather than the inherited tool map reserved for ad-hoc children. Cover index, picker and loader behavior for permitted, absent and revoked Sandbox grants without changing saved skill selections. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: nib:gpt-6.1-sol * feat(sandbox): install and exercise guest-owned PDF helpers Preserve PDF helper sources and MIT attribution in the guest image. Exercise valid, empty-text and defective PDFs during the image build and through the packaged isolated command route. Register the packaging regression in the exact suite inventory. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: nib:gpt-5 * feat(sandbox): own document skills and consolidated references Move the four document authoring trees out of core and package their text-only procedures with Sandbox. Preserve source coverage with offline fingerprints, guest PDF helper paths, and package-local license notices. Exercise actual skill receipt materialization across install, upgrade and uninstall; keep stale built-in cleanup and report-derived suite inventories current. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): explain document skill providers and unavailable selections Show provider-owned document procedures without removed built-in fallbacks, retain remaining built-in replacement notices, and translate install guidance in every locale. Cover picker labels, saved names and real API OFF normalization, and document grants, isolated execution and unchanged extraction. Acknowledge the intentionally injected GORM callback error in preference regression tests for lint. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: nib:gpt-6.1-sol * docs(agents): fix document guidance formatting Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: nib:gpt-6.1-sol * fix(ui): correct rebased skill label import Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: nib:gpt-6.1-sol --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
188 lines
8.0 KiB
Python
188 lines
8.0 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: MIT
|
|
"""Deterministic one-time consolidation and offline migration fingerprint check.
|
|
|
|
Generate before removing source trees. Check uses only committed fingerprints,
|
|
not Git/network or executable source copies. Each original Markdown page is a
|
|
bounded source block, including comments, examples and all headings. Only local
|
|
Markdown link destinations and the documented guest helper paths change.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import pathlib
|
|
import posixpath
|
|
import re
|
|
import tempfile
|
|
import shutil
|
|
|
|
FORMATS = ('docx', 'pptx', 'pdf', 'xlsx')
|
|
LINK = re.compile(r'\]\(([^)\n]+\.md(?:#[^)\n]*)?)\)')
|
|
MARK = re.compile(r'<a id="(source-[a-f0-9]{16})"></a>\n<!-- source: ([^\n]+) -->\n')
|
|
GUEST = '/usr/local/lib/localai/document-skills/pdf/'
|
|
|
|
|
|
def digest(text):
|
|
return hashlib.sha256(text.encode()).hexdigest()
|
|
|
|
|
|
def normalize(text):
|
|
text = LINK.sub('](LOCAL-MARKDOWN-TARGET)', text)
|
|
text = text.replace(GUEST, 'scripts/')
|
|
text = text.replace('The guest directory `/usr/local/lib/localai/document-skills/pdf/` contains', 'The `scripts/` directory contains')
|
|
text = text.replace('The guest directory `scripts/` contains', 'The `scripts/` directory contains')
|
|
text = text.replace('python3 scripts/', 'python scripts/')
|
|
return text
|
|
|
|
|
|
def blocks(text):
|
|
# Heading-delimited blocks retain every byte except blank edge lines.
|
|
return [digest(b.strip()) for b in re.split(r'(?m)(?=^#{1,6} )', text) if b.strip()]
|
|
|
|
|
|
def anchor(path):
|
|
return 'source-' + digest(path)[:16]
|
|
|
|
|
|
def rewrite(text, source, mapping):
|
|
def link(m):
|
|
target, _, fragment = m[1].partition('#')
|
|
if ':' in target or target.startswith('/'):
|
|
return m[0]
|
|
resolved = posixpath.normpath(posixpath.join(posixpath.dirname(source), target))
|
|
if resolved not in mapping:
|
|
raise ValueError(f'{source}: unknown link {m[1]}')
|
|
dest = mapping[resolved]
|
|
# Original input has no Markdown fragments. Fail closed if that changes.
|
|
if fragment:
|
|
raise ValueError(f'{source}: unexpected original fragment {m[1]}')
|
|
return '](' + posixpath.relpath(dest['file'], posixpath.dirname(mapping[source]['file'])) + '#' + dest['anchor'] + ')'
|
|
text = LINK.sub(link, text)
|
|
if source.startswith('pdf/'):
|
|
text = text.replace('python scripts/', 'python3 scripts/')
|
|
text = text.replace('scripts/content_qa.py', GUEST + 'content_qa.py').replace('scripts/verify_pdf.py', GUEST + 'verify_pdf.py')
|
|
text = text.replace('The `scripts/` directory contains', 'The guest directory `/usr/local/lib/localai/document-skills/pdf/` contains')
|
|
return text
|
|
|
|
|
|
def generate(args):
|
|
source = pathlib.Path(args.source)
|
|
dest = pathlib.Path(args.skills)
|
|
mapping, pages = {}, []
|
|
for skill in FORMATS:
|
|
chunk, size = 1, 0
|
|
for f in sorted((source / skill).rglob('*.md')):
|
|
p = f.relative_to(source).as_posix()
|
|
text = f.read_text()
|
|
if '/references/api/' in p:
|
|
if size + len(text.encode()) + 2048 > 200000:
|
|
chunk, size = chunk + 1, 0
|
|
out = f'{skill}/references/api-{chunk:03}.md'
|
|
size += len(text.encode()) + 2048
|
|
a = anchor(p)
|
|
else:
|
|
out, a = p, anchor(p)
|
|
mapping[p] = {'file': out, 'anchor': a}
|
|
pages.append((p, text))
|
|
proof = {'schema': 1, 'counts': {}, 'pages': []}
|
|
outputs = {}
|
|
for p, text in pages:
|
|
info = mapping[p]
|
|
changed = rewrite(text, p, mapping).rstrip("\n") + "\n"
|
|
assert blocks(normalize(text)) == blocks(normalize(changed)), p
|
|
record = dict(source=p, source_sha256=digest(text), source_blocks=blocks(normalize(text)),
|
|
output_sha256=digest(changed), **info)
|
|
proof['pages'].append(record)
|
|
proof['counts'][p.split('/')[0]] = proof['counts'].get(p.split('/')[0], 0) + 1
|
|
# SKILL frontmatter must stay first. Other pages have explicit stable anchors.
|
|
if p.endswith('/SKILL.md'):
|
|
outputs[info['file']] = changed
|
|
else:
|
|
outputs[info['file']] = outputs.get(info['file'], '') + f'<a id="{info["anchor"]}"></a>\n<!-- source: {p} -->\n' + changed + '\n'
|
|
for p, text in outputs.items():
|
|
out = dest / p
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(text.rstrip("\n") + "\n")
|
|
pathlib.Path(args.proof).write_text(json.dumps(proof, indent=2) + '\n')
|
|
check(args)
|
|
print('source comparison: all original bytes fingerprinted; only declared link/helper rewrites')
|
|
|
|
|
|
def check(args):
|
|
root = pathlib.Path(args.skills)
|
|
proof = json.loads(pathlib.Path(args.proof).read_text())
|
|
assert proof['schema'] == 1
|
|
# Inventory cardinalities and source digest set are themselves pinned in Go.
|
|
assert set(proof['counts']) == set(FORMATS)
|
|
pages = proof['pages']
|
|
assert len({p['source'] for p in pages}) == len(pages)
|
|
expected_files = {p['file'] for p in pages}
|
|
assert {p.relative_to(root).as_posix() for p in root.rglob('*.md')} == expected_files
|
|
counts, extracted = {}, {}
|
|
for filename in sorted(expected_files):
|
|
text = (root / filename).read_text()
|
|
if filename.endswith('/SKILL.md'):
|
|
extracted[filename] = text
|
|
else:
|
|
markers = list(MARK.finditer(text))
|
|
assert markers and markers[0].start() == 0, filename
|
|
for i, m in enumerate(markers):
|
|
assert m[2] not in extracted, m[2]
|
|
body = text[m.end():markers[i+1].start() if i+1 < len(markers) else len(text)]
|
|
assert body.endswith('\n')
|
|
extracted[m[2]] = body.rstrip("\n") + "\n"
|
|
assert m[1] == anchor(m[2])
|
|
for m in LINK.finditer(text):
|
|
target, _, frag = m[1].partition('#')
|
|
if ':' in target or target.startswith('/'):
|
|
continue
|
|
dest = root / posixpath.normpath(posixpath.join(posixpath.dirname(filename), target))
|
|
assert dest.is_file(), (filename, target)
|
|
if frag:
|
|
assert f'<a id="{frag}"></a>' in dest.read_text(), (filename, m[1])
|
|
assert set(extracted) == {p['source'] for p in pages}
|
|
for p in pages:
|
|
text = extracted[p['source']]
|
|
assert digest(text) == p['output_sha256'], p['source']
|
|
assert blocks(normalize(text)) == p['source_blocks'], p['source']
|
|
assert re.fullmatch('[a-f0-9]{64}', p['source_sha256'])
|
|
# Rewrites may only affect links/guest helper references, not block count.
|
|
assert p['source_blocks'], p['source']
|
|
skill = p['source'].split('/')[0]
|
|
counts[skill] = counts.get(skill, 0) + 1
|
|
assert counts == proof['counts']
|
|
print('verified source pages:', counts, 'files:', len(expected_files))
|
|
if args.self_test:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
copy = pathlib.Path(tmp) / 'skills'
|
|
shutil.copytree(root, copy)
|
|
original_root = args.skills
|
|
args.skills, args.self_test = str(copy), False
|
|
file = copy / pages[0]['file']
|
|
original = file.read_text()
|
|
for bad in (original.replace('\n', '\nOMITTED BLOCK\n', 1), original + '\n[broken](missing.md)\n'):
|
|
file.write_text(bad)
|
|
try:
|
|
check(args)
|
|
except (AssertionError, ValueError):
|
|
pass
|
|
else:
|
|
raise AssertionError('negative coverage/link check unexpectedly passed')
|
|
args.skills = original_root
|
|
print('negative coverage and broken-link fixtures rejected')
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('command', choices=('generate', 'check'))
|
|
parser.add_argument('--source')
|
|
parser.add_argument('--skills', required=True)
|
|
parser.add_argument('--proof', required=True)
|
|
parser.add_argument('--self-test', action='store_true')
|
|
args = parser.parse_args()
|
|
(generate if args.command == 'generate' else check)(args)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|