feat: 知识治理平台(含已验证发布/回滚契约)
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环): - 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口) - 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入 parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截 - 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过): * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id) * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配 * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数 * 回滚 job payload 支持 prior_execution_id(strict 解码) - 三通道发布+回滚演练实测:publish 4/4 verified,检索命中, rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false - 前端 A·Data-console 风格全站统一,120 用例通过 - 门禁:go vet / go test 全包通过,gofmt 干净
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
"""Browser acceptance for catalog navigation, routing, and refresh status."""
|
||||
import json,pathlib,urllib.request
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.support.ui import WebDriverWait,Select
|
||||
BASE='http://127.0.0.1:8088';ROOT=pathlib.Path(__file__).resolve().parents[1];TARGET='dobot_xtrainer-软件版本区别'
|
||||
options=Options();options.add_argument('--headless');options.binary_location='/snap/firefox/current/usr/lib/firefox/firefox'
|
||||
driver=webdriver.Firefox(options=options,service=Service('/snap/bin/geckodriver'));driver.set_window_size(1440,1000);wait=WebDriverWait(driver,30,poll_frequency=.1)
|
||||
def body():return driver.find_element(By.TAG_NAME,'body').text
|
||||
def source(name):driver.find_element(By.XPATH,f"//aside[@aria-label='内容来源']//button[.//b[.='{name}']]").click();wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.catalog-summary strong').text==name)
|
||||
def folder(name):driver.find_element(By.XPATH,f"//button[contains(@class,'catalog-name') and contains(.,'{name}')]").click()
|
||||
def fits():assert driver.execute_script('return document.documentElement.scrollWidth<=window.innerWidth')
|
||||
try:
|
||||
driver.get(BASE);wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源与目录');wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.catalog-source'))==7);wait.until(lambda d:d.find_elements(By.CSS_SELECTOR,'.snapshot-timestamp'))
|
||||
assert '实时刷新目录' in body() and '快照时间' in body()
|
||||
assert '实时快照已完成' in body() and '实时快照完成:1079 个目录,3444 份文件' in body()
|
||||
folder('02 产品资料');wait.until(lambda d:'返回上一级' in body());driver.find_element(By.XPATH,"//button[contains(.,'返回上一级')]").click();wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.catalog-table tbody tr'))==8)
|
||||
source('PP资料下载中心');folder('具身产品');wait.until(lambda d:'Dobot X-Trainer' in body());folder('Dobot X-Trainer');wait.until(lambda d:TARGET in body())
|
||||
operator=driver.find_element(By.CSS_SELECTOR,'.routing-fields input[placeholder="姓名或工号"]');operator.send_keys('browser-routing')
|
||||
reason=driver.find_elements(By.CSS_SELECTOR,'.routing-fields input')[1];reason.clear();reason.send_keys('网页单条与批量分流验收')
|
||||
row=driver.find_element(By.XPATH,f"//tr[.//*[contains(.,'{TARGET}')]]");choice=Select(row.find_element(By.TAG_NAME,'select'));choice.select_by_value('external');wait.until(lambda d:'已标注 1 份' in body() and '已在目标队列 1 份' in body())
|
||||
wait.until(lambda d:'对外' in d.find_element(By.XPATH,f"//tr[.//*[contains(.,'{TARGET}')]]/td[3]").text)
|
||||
# Current-page bulk selection is idempotent and should not create another publication.
|
||||
row=driver.find_element(By.XPATH,f"//tr[.//*[contains(.,'{TARGET}')]]");row.find_element(By.CSS_SELECTOR,'input[type="checkbox"]').click();driver.find_element(By.XPATH,"//button[.='批量标注对外并入队']").click();wait.until(lambda d:'已在目标队列 1 份' in body())
|
||||
assert '新入队 0 份' in body()
|
||||
for width in (1440,1024,768):driver.set_window_size(width,900);fits()
|
||||
driver.set_window_size(1440,1000);driver.get_full_page_screenshot_as_file(str(ROOT/'tmp/CATALOG_ROUTING_REFRESH_FIREFOX.png'))
|
||||
result={'back':'pass','snapshot_time':'pass','refresh_completed':'pass','single_route':'pass','bulk_route':'pass','idempotent':True,'final_visibility':'external','root_overflow':False}
|
||||
(ROOT/'tmp/catalog-routing-refresh-acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2));print(json.dumps(result,ensure_ascii=False))
|
||||
finally:driver.quit()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Route one verified PP public canary to the local external WeKnora KB."""
|
||||
import argparse,json,pathlib,time,urllib.parse,urllib.request
|
||||
GOV='http://127.0.0.1:8088';WEKNORA='http://127.0.0.1:8080/api/v1';KBS={'internal':'1b52c140-2e18-4676-afc5-4c7d8d190a8c','external':'fd7db6aa-763a-46f1-84d2-ecc7c952a5ea'};TITLE='dobot_xtrainer-软件版本区别'
|
||||
def gov(path):
|
||||
with urllib.request.urlopen(GOV+path,timeout=20) as response:return json.load(response)
|
||||
def nodes(connection,parent):return gov('/api/v1/source-catalog/nodes?'+urllib.parse.urlencode({'connection_id':connection,'parent_id':parent,'limit':100,'offset':0}))['items']
|
||||
def weknora(path):
|
||||
key=pathlib.Path(__file__).resolve().parents[1].joinpath('secrets/test_weknora_api_key').read_text().strip();request=urllib.request.Request(WEKNORA+path,headers={'X-API-Key':key,'Accept':'application/json'});return json.load(urllib.request.urlopen(request,timeout=20))
|
||||
def main():
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--visibility',choices=('internal','external'),default='external');args=parser.parse_args();kb=KBS[args.visibility]
|
||||
pp=next(item for item in gov('/api/v1/source-catalog/sources')['data'] if item['source_type']=='pp');top=next(item for item in nodes(pp['connection_id'],pp['root_node_id']) if item['name']=='具身产品');folder=next(item for item in nodes(pp['connection_id'],top['node_id']) if item['name']=='Dobot X-Trainer');file=next(item for item in nodes(pp['connection_id'],folder['node_id']) if item['name']==TITLE)
|
||||
assert file['routable_count']==1
|
||||
payload={'connection_id':pp['connection_id'],'node_ids':[file['node_id']],'visibility':args.visibility,'operator_ref':'routing-canary','reason':'验证标注后进入本地解析队列'};request=urllib.request.Request(GOV+'/api/v1/source-catalog/route',data=json.dumps(payload,ensure_ascii=False).encode(),method='POST',headers={'Content-Type':'application/json','Accept':'application/json'})
|
||||
with urllib.request.urlopen(request,timeout=20) as response:result=json.load(response)['data']
|
||||
assert result['selected_files']==1 and result['unbound_files']==0 and result['hash_unknown_files']==0
|
||||
deadline=time.monotonic()+180;matched=None
|
||||
while time.monotonic()<deadline:
|
||||
response=weknora('/knowledge-bases/'+kb+'/knowledge?page=1&page_size=100');items=response.get('data') or [];matched=next((item for item in items if item.get('title') in (TITLE,TITLE+'.txt')),None)
|
||||
if matched and matched.get('parse_status') in ('completed','failed'):break
|
||||
time.sleep(2)
|
||||
if not matched or matched.get('parse_status')!='completed':raise SystemExit('routing canary did not complete')
|
||||
print(json.dumps({'title':matched['title'],'route_result':result,'knowledge_id':matched['id'],'parse_status':matched['parse_status'],'target':'local-'+args.visibility},ensure_ascii=False))
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Read-only UI acceptance; no review, upload, retry or production writes."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import urllib.request
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.support.ui import WebDriverWait, Select
|
||||
|
||||
BASE = 'http://127.0.0.1:8088'
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
items = []
|
||||
while True:
|
||||
with urllib.request.urlopen(f'{BASE}/api/v1/source-items?limit=100&offset={len(items)}', timeout=10) as response:
|
||||
page = json.load(response)
|
||||
items.extend(page['data'])
|
||||
if len(items) >= page['summary']['total']:
|
||||
break
|
||||
assert page['data']
|
||||
assert len(items) == len({x['id'] for x in items}) == page['summary']['total']
|
||||
assert all(x['title'] for x in items)
|
||||
assert not any(x['directory_path'].startswith('/') or '://' in x['directory_path'] for x in items)
|
||||
(ROOT/'tmp/document-ui-catalog.json').write_text(json.dumps(items,ensure_ascii=False))
|
||||
options = Options()
|
||||
options.add_argument('--headless')
|
||||
options.binary_location = '/snap/firefox/current/usr/lib/firefox/firefox'
|
||||
driver = webdriver.Firefox(options=options, service=Service('/snap/bin/geckodriver'))
|
||||
wait = WebDriverWait(driver,30,poll_frequency=.15)
|
||||
driver.set_window_size(1440,1100)
|
||||
|
||||
def text():
|
||||
return driver.find_element(By.TAG_NAME,'body').text
|
||||
|
||||
def nav(label):
|
||||
driver.find_element(By.XPATH,f"//nav//button[contains(.,'{label}')]").click()
|
||||
|
||||
def loaded():
|
||||
wait.until(lambda d: not d.find_element(By.XPATH,"//header//button[contains(.,'刷新')]").get_attribute('disabled'))
|
||||
|
||||
def root_fits():
|
||||
assert driver.execute_script('return document.documentElement.scrollWidth <= window.innerWidth'), 'root overflow'
|
||||
|
||||
try:
|
||||
driver.get(BASE)
|
||||
wait.until(lambda d: d.find_element(By.TAG_NAME,'h1').text == '来源与目录')
|
||||
loaded()
|
||||
assert not driver.find_elements(By.CSS_SELECTOR,'[role="alert"]')
|
||||
assert '未接通目录' in text()
|
||||
assert '只走这 8 步' not in text()
|
||||
expected_external = sum(x['visibility']=='external' for x in items)
|
||||
counts = driver.find_element(By.CSS_SELECTOR,'.document-counts').text
|
||||
assert str(len(items)) in counts and str(expected_external) in counts
|
||||
first = next(x for x in items if x['directory_path'] and x['source_type']=='pp')
|
||||
folder = next(e for e in driver.find_elements(By.CSS_SELECTOR,'.folder.child') if first['directory_path'] in e.text)
|
||||
folder.click()
|
||||
wait.until(lambda d: first['directory_path'] in d.find_element(By.CSS_SELECTOR,'.document-location').text)
|
||||
rows = driver.find_elements(By.CSS_SELECTOR,'.document-table tbody tr')
|
||||
assert rows and all(first['directory_path'] in r.text for r in rows)
|
||||
driver.find_element(By.CSS_SELECTOR,'.folder').click()
|
||||
search = driver.find_element(By.CSS_SELECTOR,'input[type="search"]')
|
||||
search.send_keys(first['title'])
|
||||
wait.until(lambda d: first['title'] in d.find_element(By.CSS_SELECTOR,'.document-table').text)
|
||||
driver.find_element(By.CSS_SELECTOR,'.file-link').click()
|
||||
detail = wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.document-inspector'))
|
||||
assert detail.is_displayed() and first['title'] in detail.text
|
||||
assert '原文预览尚未接通' in detail.text
|
||||
driver.find_element(By.XPATH,"//button[.='关闭详情']").click()
|
||||
search.clear()
|
||||
search.send_keys(' ')
|
||||
selects = driver.find_elements(By.CSS_SELECTOR,'.document-filters select')
|
||||
Select(selects[0]).select_by_value('internal')
|
||||
wait.until(lambda d: '筛选结果 5 份' in d.find_element(By.CSS_SELECTOR,'.document-location').text)
|
||||
assert len(driver.find_elements(By.CSS_SELECTOR,'.document-table tbody tr')) == 5
|
||||
Select(selects[0]).select_by_value('')
|
||||
root_fits()
|
||||
assert driver.execute_script('return document.documentElement.scrollHeight < window.innerHeight + 300'), 'document table should scroll inside its panel'
|
||||
driver.get_full_page_screenshot_as_file(str(ROOT/'tmp/DOCUMENT_WORKBENCH_FIREFOX.png'))
|
||||
nav('解析监控')
|
||||
wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='解析监控')
|
||||
loaded()
|
||||
before = driver.execute_script("return performance.getEntriesByType('resource').filter(x=>x.name.includes('/source-items?')).length")
|
||||
WebDriverWait(driver,25).until(lambda d:d.execute_script("return performance.getEntriesByType('resource').filter(x=>x.name.includes('/source-items?')).length") > before)
|
||||
loaded()
|
||||
assert '解析完成不代表已上线' in text()
|
||||
nav('待处理文件')
|
||||
wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='待处理文件')
|
||||
expected_attention = sum(x['content_state']=='over_limit' or x['processing_state'] in ('failed','stalled') for x in items)
|
||||
assert f'筛选结果 {expected_attention} 份' in text()
|
||||
nav('来源连接')
|
||||
wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源连接')
|
||||
assert not driver.find_element(By.CSS_SELECTOR,'.scan-audit-panel').get_attribute('open')
|
||||
nav('发布管理')
|
||||
wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='从审核到安全发布')
|
||||
assert not driver.find_element(By.CSS_SELECTOR,'.release-help').get_attribute('open')
|
||||
assert '只走这 8 步' not in text()
|
||||
assert not driver.find_elements(By.XPATH,"//button[.='授权生产发布']")
|
||||
nav('来源与目录')
|
||||
loaded()
|
||||
for width in (1440,1024,768):
|
||||
driver.set_window_size(width,1000)
|
||||
root_fits()
|
||||
driver.refresh()
|
||||
wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源与目录')
|
||||
assert driver.find_element(By.CSS_SELECTOR,'.tab-list').text.count('解析监控')==1
|
||||
result = {'catalog':len(items),'titles':sum(bool(x['title']) for x in items),'categories':sum(bool(x['directory_path']) for x in items),'external':expected_external,'attention':expected_attention,'search':'pass','directory_filter':'pass','scope_filter':'pass','file_drawer':'pass','automatic_refresh':'pass','tabs_restore':'pass','root_overflow':False,'write_requests':0}
|
||||
(ROOT/'tmp/document-ui-acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
|
||||
print(json.dumps(result,ensure_ascii=False))
|
||||
finally:
|
||||
driver.quit()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Live browser acceptance for document workbench V2.
|
||||
|
||||
Writes one PP category rule and one file override through the UI, verifies each
|
||||
read-back, then clears both. It never uploads, retries, reviews or publishes.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
import urllib.request
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.support.ui import WebDriverWait, Select
|
||||
|
||||
BASE='http://127.0.0.1:8088'
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
def request_json(path,method='GET',body=None):
|
||||
data=None if body is None else json.dumps(body,ensure_ascii=False).encode()
|
||||
request=urllib.request.Request(BASE+path,data=data,method=method,headers={'Accept':'application/json','Content-Type':'application/json'})
|
||||
with urllib.request.urlopen(request,timeout=10) as response:return json.load(response)
|
||||
|
||||
def catalog():
|
||||
items=[]
|
||||
while True:
|
||||
page=request_json(f'/api/v1/source-items?limit=100&offset={len(items)}')
|
||||
items.extend(page['data'])
|
||||
if len(items)>=page['summary']['total']:break
|
||||
assert page['data']
|
||||
assert len(items)==len({item['id'] for item in items})==page['summary']['total']
|
||||
return items
|
||||
|
||||
def rules(connection):return request_json('/api/v1/scope-rules?connection_id='+connection)['data']
|
||||
def eventually(check,seconds=25):
|
||||
deadline=time.monotonic()+seconds
|
||||
while time.monotonic()<deadline:
|
||||
value=check()
|
||||
if value:return value
|
||||
time.sleep(.2)
|
||||
raise AssertionError('condition did not become true')
|
||||
|
||||
before=catalog()
|
||||
pp=[item for item in before if item['source_type']=='pp']
|
||||
manual=[item for item in before if item['source_type']=='manual']
|
||||
category=[item for item in pp if item['directory_path']=='产品介绍']
|
||||
assert len(before)==618 and len(pp)==613 and len(manual)==5 and len(category)==5
|
||||
connection=category[0]['connection_id']
|
||||
assert not rules(connection),'acceptance requires no pre-existing PP scope rule'
|
||||
selected_id=category[0]['id']
|
||||
|
||||
options=Options();options.add_argument('--headless');options.binary_location='/snap/firefox/current/usr/lib/firefox/firefox'
|
||||
driver=webdriver.Firefox(options=options,service=Service('/snap/bin/geckodriver'))
|
||||
driver.set_window_size(1440,1100)
|
||||
wait=WebDriverWait(driver,30,poll_frequency=.1)
|
||||
write_actions=0
|
||||
|
||||
def body():return driver.find_element(By.TAG_NAME,'body').text
|
||||
def root_fits():assert driver.execute_script('return document.documentElement.scrollWidth <= window.innerWidth'),'root overflow'
|
||||
def fill_editor(editor,operator,reason,visibility):
|
||||
Select(editor.find_element(By.TAG_NAME,'select')).select_by_value(visibility)
|
||||
operator_input=editor.find_element(By.CSS_SELECTOR,'input[placeholder="姓名或工号"]');operator_input.clear();operator_input.send_keys(operator)
|
||||
reason_input=editor.find_element(By.CSS_SELECTOR,'input[placeholder*="官网"]');reason_input.clear();reason_input.send_keys(reason)
|
||||
|
||||
def api_category(expected,origin):
|
||||
current=[item for item in catalog() if item['connection_id']==connection and item['directory_path']=='产品介绍']
|
||||
return len(current)==5 and all(item['visibility']==expected and item['visibility_origin']==origin for item in current)
|
||||
|
||||
try:
|
||||
driver.get(BASE)
|
||||
wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源与目录')
|
||||
wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.directory-panel .folder b').text=='613')
|
||||
assert 'P3 持久队列' not in body()
|
||||
assert '显示 5 份验收文档' in body()
|
||||
assert driver.find_element(By.CSS_SELECTOR,'.document-counts span:first-child b').text=='613'
|
||||
root_fits()
|
||||
# Test data is deliberately opt-in and returns to hidden state.
|
||||
toggle=driver.find_element(By.CSS_SELECTOR,'.test-document-toggle input');toggle.click()
|
||||
wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.directory-panel .folder b').text=='618')
|
||||
toggle.click();wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.directory-panel .folder b').text=='613')
|
||||
# Select the real PP category.
|
||||
driver.find_element(By.XPATH,"//aside//button[.//span[contains(.,'PP公开文档')]]").click()
|
||||
wait.until(lambda d:d.find_elements(By.CSS_SELECTOR,'.scope-editor'))
|
||||
driver.find_element(By.XPATH,"//aside//button[.//span[contains(.,'产品介绍')]]").click()
|
||||
editor=wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.document-content .scope-editor'))
|
||||
assert '此目录及子目录' in editor.text and '当前没有本级设置' in editor.text
|
||||
fill_editor(editor,'browser-acceptance','验证目录继承,完成后恢复','internal')
|
||||
editor.find_element(By.XPATH,".//button[.='保存范围']").click();write_actions+=1
|
||||
eventually(lambda:api_category('internal','directory_rule'))
|
||||
wait.until(lambda d:'对内' in d.find_element(By.CSS_SELECTOR,'.document-table tbody').text)
|
||||
assert 'PP公开文档' in driver.find_element(By.CSS_SELECTOR,'.workspace-context-tabs').text
|
||||
assert '产品介绍' in driver.find_element(By.CSS_SELECTOR,'.workspace-context-tabs').text
|
||||
# Override one file, verify precedence, then restore inheritance.
|
||||
first_link=driver.find_element(By.CSS_SELECTOR,'.file-link');file_title=first_link.text;first_link.click()
|
||||
inspector=wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.document-inspector'))
|
||||
for expected in ['取得文件','上传测试库','解析与索引','测试检索','原文与版本差异尚未接通']:
|
||||
assert expected in inspector.text
|
||||
assert file_title in driver.find_element(By.CSS_SELECTOR,'.workspace-context-tabs').text
|
||||
file_editor=inspector.find_element(By.CSS_SELECTOR,'.scope-editor')
|
||||
fill_editor(file_editor,'browser-acceptance','验证文件单独覆盖,完成后恢复','external')
|
||||
file_editor.find_element(By.XPATH,".//button[.='保存范围']").click();write_actions+=1
|
||||
eventually(lambda:next(item for item in catalog() if item['id']==selected_id)['visibility_origin']=='file_override')
|
||||
wait.until(lambda d:'当前设置:对外' in d.find_element(By.CSS_SELECTOR,'.document-inspector').text)
|
||||
file_editor=driver.find_element(By.CSS_SELECTOR,'.document-inspector .scope-editor')
|
||||
fill_editor(file_editor,'browser-acceptance','恢复目录继承','external')
|
||||
file_editor.find_element(By.XPATH,".//button[contains(.,'恢复继承')]").click();write_actions+=1
|
||||
eventually(lambda:(lambda item:item['visibility']=='internal' and item['visibility_origin']=='directory_rule')(next(item for item in catalog() if item['id']==selected_id)))
|
||||
# Clicking the source context closes directory/file context.
|
||||
driver.find_element(By.XPATH,"//div[@aria-label='当前来源目录和文件']//button[.='PP公开文档']").click()
|
||||
wait.until(lambda d:not d.find_elements(By.CSS_SELECTOR,'.document-inspector'))
|
||||
assert '产品介绍' not in driver.find_element(By.CSS_SELECTOR,'.workspace-context-tabs').text
|
||||
# Return to the category and clear its direct rule.
|
||||
driver.find_element(By.XPATH,"//aside//button[.//span[contains(.,'产品介绍')]]").click()
|
||||
editor=wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.document-content .scope-editor'))
|
||||
fill_editor(editor,'browser-acceptance','恢复来源原始标记','internal')
|
||||
editor.find_element(By.XPATH,".//button[contains(.,'恢复继承')]").click();write_actions+=1
|
||||
eventually(lambda:not rules(connection))
|
||||
eventually(lambda:api_category('external','pp_public_tag'))
|
||||
assert write_actions==4
|
||||
# Source-level truthful monitor reconciles every PP item.
|
||||
driver.find_element(By.XPATH,"//nav//button[contains(.,'解析监控')]").click()
|
||||
wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='解析监控')
|
||||
card=wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.source-progress-card'))
|
||||
for expected in ['PP公开文档','已就绪 559 / 613','独立解析 530','处理中 0','需处理 54','内容复用 29','等待 0']:
|
||||
assert expected in card.text
|
||||
assert '100%' not in body()
|
||||
root_fits()
|
||||
driver.get_full_page_screenshot_as_file(str(ROOT/'tmp/DOCUMENT_MONITOR_V2_FIREFOX.png'))
|
||||
driver.find_element(By.XPATH,"//nav//button[contains(.,'来源与目录')]").click()
|
||||
wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源与目录')
|
||||
for width in (1440,1024,768):
|
||||
driver.set_window_size(width,1000);root_fits()
|
||||
driver.set_window_size(1440,1100)
|
||||
driver.get_full_page_screenshot_as_file(str(ROOT/'tmp/DOCUMENT_WORKBENCH_V2_FIREFOX.png'))
|
||||
capabilities=request_json('/api/v1/release-capabilities')['data']
|
||||
assert capabilities['production_release_requests_enabled'] is False
|
||||
result={'catalog':618,'default_visible':613,'test_documents_hidden':5,'category':'产品介绍','category_items':5,'directory_inheritance':'pass','file_override':'pass','clear_restores_source':'pass','scope_writes':4,'active_rules_after_test':0,'source_monitor':{'completed':530,'total':613,'active':0,'attention':54,'reused':29,'waiting':0},'context_tabs':'pass','pipeline_detail':'pass','root_overflow':False,'production_release_requests_enabled':False}
|
||||
(ROOT/'tmp/document-workbench-v2-acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
|
||||
print(json.dumps(result,ensure_ascii=False))
|
||||
finally:
|
||||
driver.quit()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Trigger one catalog refresh and verify its durable terminal state."""
|
||||
import argparse,json,time,urllib.request,uuid
|
||||
BASE='http://127.0.0.1:8088'
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE+path,timeout=20) as response:return json.load(response)
|
||||
def main():
|
||||
parser=argparse.ArgumentParser();parser.add_argument('name');parser.add_argument('--timeout',type=int,default=600);args=parser.parse_args();source=next(item for item in get('/api/v1/source-catalog/sources')['data'] if item['name']==args.name);before=source['observed_at'];request=urllib.request.Request(BASE+f"/api/v1/source-catalog/sources/{source['connection_id']}/refresh",method='POST',headers={'Accept':'application/json','Idempotency-Key':'accept-'+uuid.uuid4().hex})
|
||||
with urllib.request.urlopen(request,timeout=20) as response:job=json.load(response)['data']
|
||||
print('job',job['id'],'source',args.name,'before',before,flush=True);seen=None;deadline=time.monotonic()+args.timeout
|
||||
while time.monotonic()<deadline:
|
||||
current=next(item for item in get('/api/v1/jobs?limit=50')['data'] if item['id']==job['id']);payload=current.get('payload') or {};state=(current['state'],payload.get('stage'),payload.get('progress'),payload.get('message'),payload.get('processed'),payload.get('total'))
|
||||
if state!=seen:print(*state,flush=True);seen=state
|
||||
if current['state'] in ('completed','failed'):break
|
||||
time.sleep(1)
|
||||
else:raise SystemExit('refresh timeout')
|
||||
after=next(item for item in get('/api/v1/source-catalog/sources')['data'] if item['name']==args.name)
|
||||
print('final',current['state'],'after',after['observed_at'],'files',after['file_count'],flush=True)
|
||||
if current['state']!='completed' or after['observed_at']==before:raise SystemExit(1)
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Read-only acceptance for the explicitly simulated WeKnora monitor."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import urllib.request
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
BASE='http://127.0.0.1:8088';ROOT=Path(__file__).resolve().parents[1]
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE+path,timeout=10) as response:return json.load(response)
|
||||
overview=get('/api/v1/monitor/overview');assert overview['mode']=='simulation' and overview['simulated'] is True
|
||||
kb=overview['knowledge_bases'][0];assert kb['imported']==5750 and sum(kb[key] for key in ('completed','pending','processing','finalizing','failed','cancelled'))==5750
|
||||
options=Options();options.add_argument('--headless');options.binary_location='/snap/firefox/current/usr/lib/firefox/firefox'
|
||||
driver=webdriver.Firefox(options=options,service=Service('/snap/bin/geckodriver'));driver.set_window_size(1440,1000);wait=WebDriverWait(driver,30,poll_frequency=.1)
|
||||
def body():return driver.find_element(By.TAG_NAME,'body').text
|
||||
def fits():assert driver.execute_script('return document.documentElement.scrollWidth<=window.innerWidth')
|
||||
try:
|
||||
driver.get(BASE);wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源与目录')
|
||||
driver.find_element(By.XPATH,"//nav//button[contains(.,'解析监控')]").click();wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='解析监控');wait.until(lambda d:'completed 4530' in d.find_element(By.CSS_SELECTOR,'.monitor-stage-tabs').text)
|
||||
for expected in ['本地模拟数据','不代表测试服务器真实数据','CPU','42%','内存','18.6 / 32.0 GB','WeKnora API','Embedding模型','LLM模型','MinerU','已导入 5750','completed 4530','pending 450','finalizing 714','processing 50','failed 6','对内 3300 / 4200','对外 1230 / 1550']:
|
||||
assert expected in body()
|
||||
assert '100%' not in body()
|
||||
driver.find_element(By.XPATH,"//div[contains(@class,'monitor-stage-tabs')]//button[contains(.,'finalizing')]").click();wait.until(lambda d:'[模拟] 产品参数总表.xlsx' in d.find_element(By.CSS_SELECTOR,'.monitor-file-table').text)
|
||||
assert '基础检索可用,Wiki与图谱仍在生成' in body()
|
||||
driver.find_element(By.XPATH,"//div[contains(@class,'monitor-stage-tabs')]//button[contains(.,'failed')]").click();wait.until(lambda d:'[模拟] CRA-IP68产品介绍.pdf' in d.find_element(By.CSS_SELECTOR,'.monitor-file-table').text)
|
||||
assert 'mineru_parse_failed' in body()
|
||||
driver.find_element(By.XPATH,"//div[contains(@class,'monitor-stage-tabs')]//button[contains(.,'全部')]").click();wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.monitor-file-table tbody tr'))==5)
|
||||
for width in (1440,1024,768):driver.set_window_size(width,900);fits()
|
||||
driver.set_window_size(1440,1000);driver.get_full_page_screenshot_as_file(str(ROOT/'tmp/WEKNORA_MONITOR_SIMULATION_FIREFOX.png'))
|
||||
result={'mode':'simulation','explicit_label':True,'imported':5750,'completed':4530,'pending':450,'finalizing':714,'processing':50,'failed':6,'status_filter':'pass','file_names':'pass','resource_cards':'pass','root_overflow':False,'write_requests':0}
|
||||
(ROOT/'tmp/weknora-monitor-simulation-acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2));print(json.dumps(result,ensure_ascii=False))
|
||||
finally:driver.quit()
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Verify the web refresh button starts a job and shows actionable failure."""
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
BASE='http://127.0.0.1:8088'
|
||||
options=Options();options.add_argument('--headless');options.binary_location='/snap/firefox/current/usr/lib/firefox/firefox'
|
||||
driver=webdriver.Firefox(options=options,service=Service('/snap/bin/geckodriver'));wait=WebDriverWait(driver,40,poll_frequency=.2)
|
||||
try:
|
||||
driver.get(BASE);wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源与目录');wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.catalog-source'))==7)
|
||||
driver.find_element(By.XPATH,"//aside[@aria-label='内容来源']//button[.//b[.='PP资料下载中心']]").click();wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.catalog-summary strong').text=='PP资料下载中心')
|
||||
before=driver.find_element(By.CSS_SELECTOR,'.snapshot-timestamp').text
|
||||
driver.find_element(By.XPATH,"//button[.='实时刷新目录']").click();wait.until(lambda d:'实时刷新中' in d.find_element(By.CSS_SELECTOR,'.catalog-refresh-progress').text)
|
||||
wait.until(lambda d:'PP登录失败,请在来源链接更新凭据' in d.find_element(By.CSS_SELECTOR,'.catalog-refresh-progress').text and '旧快照仍然保留' in d.find_element(By.CSS_SELECTOR,'.catalog-refresh-progress').text)
|
||||
after=driver.find_element(By.CSS_SELECTOR,'.snapshot-timestamp').text;assert before==after
|
||||
print('pp_ui_refresh_failure_pass snapshot_preserved')
|
||||
finally:driver.quit()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Read-only browser acceptance for the local release planner simulation."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import urllib.request
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
BASE='http://127.0.0.1:8088';ROOT=Path(__file__).resolve().parents[1]
|
||||
options=Options();options.add_argument('--headless');options.binary_location='/snap/firefox/current/usr/lib/firefox/firefox'
|
||||
driver=webdriver.Firefox(options=options,service=Service('/snap/bin/geckodriver'));driver.set_window_size(1440,1000);wait=WebDriverWait(driver,30,poll_frequency=.1)
|
||||
def body():return driver.find_element(By.TAG_NAME,'body').text
|
||||
def fits():assert driver.execute_script('return document.documentElement.scrollWidth<=window.innerWidth')
|
||||
try:
|
||||
driver.get(BASE);wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源与目录');driver.find_element(By.XPATH,"//nav//button[contains(.,'发布管理')]").click();wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='发布管理')
|
||||
for expected in ['本地模拟发布准备','1. 选择文档','2. 检查数据闭包','3. 锁定清单','4. 生产负责人授权','[模拟] CR5用户手册.pdf','[模拟] 产品参数总表.xlsx','图谱 共享','[模拟] CRA-IP68产品介绍.pdf','生产发布已关闭','生产备份到OSS:尚未配置']:
|
||||
assert expected in body()
|
||||
boxes=driver.find_elements(By.CSS_SELECTOR,'.release-candidate input[type="checkbox"]');assert len(boxes)==3 and boxes[2].get_attribute('disabled')
|
||||
boxes[0].click();wait.until(lambda d:'文档级同步' in d.find_element(By.CSS_SELECTOR,'.release-mode').text)
|
||||
driver.find_element(By.XPATH,"//button[.='生成模拟清单']").click();wait.until(lambda d:'模拟清单已生成' in body())
|
||||
boxes=driver.find_elements(By.CSS_SELECTOR,'.release-candidate input[type="checkbox"]');boxes[1].click();wait.until(lambda d:'整知识库同步' in d.find_element(By.CSS_SELECTOR,'.release-mode').text)
|
||||
assert '不会覆盖整个WeKnora数据库' in body()
|
||||
driver.find_element(By.XPATH,"//button[.='清空选择']").click();wait.until(lambda d:'尚未选择' in d.find_element(By.CSS_SELECTOR,'.release-mode').text)
|
||||
assert not driver.find_elements(By.XPATH,"//button[.='授权生产发布']")
|
||||
capabilities=json.load(urllib.request.urlopen(BASE+'/api/v1/release-capabilities',timeout=10))['data'];assert capabilities['production_release_requests_enabled'] is False
|
||||
for width in (1440,1024,768):driver.set_window_size(width,900);fits()
|
||||
driver.set_window_size(1440,1000);driver.get_full_page_screenshot_as_file(str(ROOT/'tmp/RELEASE_PLANNER_SIMULATION_FIREFOX.png'))
|
||||
result={'mode':'simulation','candidates':3,'document_plan':'pass','knowledge_base_fallback':'pass','failed_blocked':'pass','four_steps':'pass','production_release_requests_enabled':False,'root_overflow':False,'external_write_requests':0}
|
||||
(ROOT/'tmp/release-planner-simulation-acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2));print(json.dumps(result,ensure_ascii=False))
|
||||
finally:driver.quit()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Read-only browser acceptance for the real multi-level source catalog."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import urllib.request
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
BASE='http://127.0.0.1:8088';ROOT=Path(__file__).resolve().parents[1]
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE+path,timeout=30) as response:return json.load(response)
|
||||
def source(name):return next(item for item in get('/api/v1/source-catalog/sources')['data'] if item['name']==name)
|
||||
def page(item,parent):return get(f"/api/v1/source-catalog/nodes?connection_id={item['connection_id']}&parent_id={parent}&limit=100&offset=0")
|
||||
|
||||
sources=get('/api/v1/source-catalog/sources')['data'];assert len(sources)==7
|
||||
options=Options();options.add_argument('--headless');options.binary_location='/snap/firefox/current/usr/lib/firefox/firefox'
|
||||
driver=webdriver.Firefox(options=options,service=Service('/snap/bin/geckodriver'));driver.set_window_size(1440,1000);wait=WebDriverWait(driver,30,poll_frequency=.1)
|
||||
def body():return driver.find_element(By.TAG_NAME,'body').text
|
||||
def fits():assert driver.execute_script('return document.documentElement.scrollWidth<=window.innerWidth'),'page horizontal overflow'
|
||||
def source_button(name):return driver.find_element(By.XPATH,f"//aside[@aria-label='内容来源']//button[.//b[.='{name}']]")
|
||||
def folder_button(fragment):return driver.find_element(By.XPATH,f"//table[contains(@class,'catalog-table')]//button[contains(.,'{fragment}')]")
|
||||
def row_names():return [row.find_element(By.CSS_SELECTOR,'td:first-child').text.replace('📁','').replace('📄','').strip() for row in driver.find_elements(By.CSS_SELECTOR,'.catalog-table tbody tr')]
|
||||
try:
|
||||
driver.get(BASE);wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源与目录');wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.catalog-source'))==7)
|
||||
company=source('公司级技术支持知识库');company_root=page(company,company['root_node_id']);company_folders=[node['name'] for node in company_root['items'] if node['node_type']=='folder']
|
||||
wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.catalog-table tbody tr'))==len(company_folders))
|
||||
assert row_names()==company_folders and len(company_folders)==8
|
||||
for expected in ['01 产品总览与定位','02 产品资料','03 机器人部署与使用','04 异常处理与售后支持','05 二次开发','06 应用案例与解决方案','07 培训与学习','99 隔离区','另有 5 份根目录文件']:
|
||||
assert expected in body()
|
||||
assert all(row.find_element(By.CSS_SELECTOR,'td:nth-child(2)').text=='文件夹' for row in driver.find_elements(By.CSS_SELECTOR,'.catalog-table tbody tr'))
|
||||
# Enter one level and compare the browser exactly with the API's direct children.
|
||||
target=next(node for node in company_root['items'] if node['node_type']=='folder' and node['name'].startswith('02 产品资料'))
|
||||
folder_button('02 产品资料').click();target_page=page(company,target['node_id']);wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.workspace-context-tabs button'))==2)
|
||||
wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.catalog-table tbody tr'))==len(target_page['items']))
|
||||
assert row_names()==[node['name'] for node in target_page['items']]
|
||||
assert '02 产品资料' in driver.find_element(By.CSS_SELECTOR,'.workspace-context-tabs').text
|
||||
nested=next((node for node in target_page['items'] if node['node_type']=='folder'),None)
|
||||
if nested:
|
||||
folder_button(nested['name'][:12]).click();wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.workspace-context-tabs button'))==3)
|
||||
# Top context returns to the source root.
|
||||
driver.find_element(By.XPATH,"//div[@aria-label='当前来源目录和文件']//button[.='公司级技术支持知识库']").click();wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.catalog-table tbody tr'))==8)
|
||||
# PP root: only the eight approved real tag types, 16 untagged files collapsed.
|
||||
pp=source('PP资料下载中心');source_button('PP资料下载中心').click();wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.catalog-summary strong').text=='PP资料下载中心')
|
||||
expected_pp=['工业产品','教育产品','具身产品','多足产品','解决方案','公司','软件','生态&配件'];wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.catalog-table tbody tr'))==8)
|
||||
assert row_names()==expected_pp and '另有 16 份根目录文件' in body()
|
||||
folder_button('工业产品').click();wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.workspace-context-tabs button'))==2)
|
||||
assert 'CRA系列' in body()
|
||||
folder_button('CRA系列').click();wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.workspace-context-tabs button'))==3)
|
||||
assert driver.find_elements(By.CSS_SELECTOR,'.catalog-file')
|
||||
# AList domestic-industrial root is exactly six first-level folders.
|
||||
alist=source('国内工业');source_button('国内工业').click();wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.catalog-summary strong').text=='国内工业')
|
||||
expected_alist=['01 公司资料','02 产品资料','03 行业洞察','04 销售赋能','05 流程制度','06 通用模板'];wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.catalog-table tbody tr'))==6)
|
||||
assert row_names()==expected_alist
|
||||
folder_button('02 产品资料').click();wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.workspace-context-tabs button'))==2)
|
||||
assert '国内工业' in driver.find_element(By.CSS_SELECTOR,'.workspace-context-tabs').text and '02 产品资料' in driver.find_element(By.CSS_SELECTOR,'.workspace-context-tabs').text
|
||||
for width in (1440,1024,768):driver.set_window_size(width,900);fits()
|
||||
driver.set_window_size(1440,1000);driver.get_full_page_screenshot_as_file(str(ROOT/'tmp/SOURCE_CATALOG_FIREFOX.png'))
|
||||
result={'sources':7,'nodes':38986,'company_first_level':8,'company_root_files_collapsed':5,'pp_first_level':8,'pp_root_files_collapsed':16,'alist_domestic_first_level':6,'direct_children_match_api':True,'breadcrumb_depth_tested':3,'context_navigation':'pass','root_overflow':False,'write_requests':0}
|
||||
(ROOT/'tmp/source-catalog-acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2));print(json.dumps(result,ensure_ascii=False))
|
||||
finally:driver.quit()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Read-only acceptance for local simulated source-link configuration."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
BASE='http://127.0.0.1:8088';ROOT=Path(__file__).resolve().parents[1]
|
||||
options=Options();options.add_argument('--headless');options.binary_location='/snap/firefox/current/usr/lib/firefox/firefox'
|
||||
driver=webdriver.Firefox(options=options,service=Service('/snap/bin/geckodriver'));driver.set_window_size(1440,1000);wait=WebDriverWait(driver,30,poll_frequency=.1)
|
||||
def body():return driver.find_element(By.TAG_NAME,'body').text
|
||||
def click_source(name):driver.find_element(By.XPATH,f"//aside[contains(@class,'source-link-list')]//button[.//b[.='{name}']]").click();wait.until(lambda d:d.find_element(By.CSS_SELECTOR,'.source-link-title h2').text==name)
|
||||
def fits():assert driver.execute_script('return document.documentElement.scrollWidth<=window.innerWidth')
|
||||
try:
|
||||
driver.get(BASE);wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源与目录');driver.find_element(By.XPATH,"//nav//button[contains(.,'来源链接')]").click();wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源链接');wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.source-link-list button'))==7)
|
||||
for expected in ['本地模拟配置','公司级技术支持知识库','PP资料下载中心','国内工业','知识库链接','认证方式','Key或Token','模拟登录并验证目录','不保存真实Key','模拟验证待执行']:
|
||||
assert expected in body(),expected
|
||||
auth_input=driver.find_element(By.XPATH,"//label[contains(.,'认证方式')]/input");assert auth_input.get_attribute('value')=='专用浏览器扫码登录'
|
||||
key_input=driver.find_element(By.XPATH,"//label[contains(.,'Key或Token')]/input");assert key_input.get_attribute('disabled') and key_input.get_attribute('placeholder')=='本地模拟阶段不接收真实Key'
|
||||
driver.find_element(By.CSS_SELECTOR,'.validate-source-link').click();wait.until(lambda d:len(d.find_elements(By.CSS_SELECTOR,'.validation-results b'))==5);assert all(x.text=='模拟通过' for x in driver.find_elements(By.CSS_SELECTOR,'.validation-results b'))
|
||||
click_source('PP资料下载中心');assert driver.find_element(By.XPATH,"//label[contains(.,'认证方式')]/input").get_attribute('value')=='公开访问,无需Key' and '模拟验证公开目录' in body()
|
||||
click_source('国内工业');assert driver.find_element(By.XPATH,"//label[contains(.,'认证方式')]/input").get_attribute('value')=='只读Token' and '模拟验证只读Token' in body() and '不执行上传、移动、重命名或删除' in body()
|
||||
for width in (1440,1024,768):driver.set_window_size(width,900);fits()
|
||||
driver.set_window_size(1440,1000);driver.get_full_page_screenshot_as_file(str(ROOT/'tmp/SOURCE_LINK_SIMULATION_FIREFOX.png'))
|
||||
result={'sources':7,'mode':'simulation','dingtalk_login':'pass','pp_public':'pass','alist_readonly_token':'pass','real_key_input_disabled':True,'validation_steps':5,'root_overflow':False,'external_write_requests':0}
|
||||
(ROOT/'tmp/source-link-simulation-acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2));print(json.dumps(result,ensure_ascii=False))
|
||||
finally:driver.quit()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Read-only acceptance for local simulated system settings."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import urllib.request
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.support.ui import WebDriverWait,Select
|
||||
BASE='http://127.0.0.1:8088';ROOT=Path(__file__).resolve().parents[1]
|
||||
options=Options();options.add_argument('--headless');options.binary_location='/snap/firefox/current/usr/lib/firefox/firefox'
|
||||
driver=webdriver.Firefox(options=options,service=Service('/snap/bin/geckodriver'));driver.set_window_size(1440,1100);wait=WebDriverWait(driver,30,poll_frequency=.1)
|
||||
def body():return driver.find_element(By.TAG_NAME,'body').text
|
||||
def fits():assert driver.execute_script('return document.documentElement.scrollWidth<=window.innerWidth')
|
||||
try:
|
||||
driver.get(BASE);wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='来源与目录');driver.find_element(By.XPATH,"//nav//button[contains(.,'系统设置')]").click();wait.until(lambda d:d.find_element(By.TAG_NAME,'h1').text=='系统设置');wait.until(lambda d:'6. 监控与安全' in d.find_element(By.TAG_NAME,'body').text)
|
||||
for expected in ['本地模拟','文档工程师可调整','管理员设置已锁定','1. 当前环境','2. WeKnora环境','3. 解析器与模型','4. 发布目标','5. OSS备份','6. 监控与安全','测试WeKnora','生产WeKnora','MinerU','Embedding模型','LLM模型','Rerank模型','Wiki生成模型','图谱抽取模型','PostgreSQL','Elasticsearch','对象存储','Neo4j','生产发布请求','关闭','备份Bucket','文档状态刷新间隔','15秒','解析停滞判定','30分钟','保存模拟设置']:
|
||||
assert expected in body(),expected
|
||||
selects=driver.find_elements(By.CSS_SELECTOR,'.safe-setting-grid select');Select(selects[0]).select_by_visible_text('30秒');Select(selects[2]).select_by_visible_text('1小时');driver.find_element(By.XPATH,"//button[.='保存模拟设置']").click();wait.until(lambda d:'模拟设置已保存' in body())
|
||||
assert all(button.get_attribute('disabled') for button in driver.find_elements(By.XPATH,"//button[contains(.,'管理员')]") )
|
||||
assert not driver.find_elements(By.CSS_SELECTOR,'input[type="password"]')
|
||||
capabilities=json.load(urllib.request.urlopen(BASE+'/api/v1/release-capabilities',timeout=10))['data'];assert capabilities['production_release_requests_enabled'] is False
|
||||
for width in (1440,1024,768):driver.set_window_size(width,1000);fits()
|
||||
driver.set_window_size(1440,1100);driver.get_full_page_screenshot_as_file(str(ROOT/'tmp/SYSTEM_SETTINGS_SIMULATION_FIREFOX.png'))
|
||||
result={'mode':'simulation','sections':6,'safe_settings_change':'pass','admin_controls_locked':True,'password_inputs':0,'production_release_requests_enabled':False,'root_overflow':False,'external_write_requests':0}
|
||||
(ROOT/'tmp/system-settings-simulation-acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2));print(json.dumps(result,ensure_ascii=False))
|
||||
finally:driver.quit()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Idempotently create isolated local internal/external routing KBs."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import urllib.request
|
||||
BASE='http://127.0.0.1:8080/api/v1';KEY_FILE=Path(__file__).resolve().parents[1]/'secrets/test_weknora_api_key';TEMPLATE_ID='c12ff4d5-dd87-423b-ab0f-af745b9d54a7'
|
||||
def request(method,path,payload=None):
|
||||
key=KEY_FILE.read_text().strip();data=None if payload is None else json.dumps(payload,ensure_ascii=False).encode();req=urllib.request.Request(BASE+path,data=data,method=method,headers={'X-API-Key':key,'Accept':'application/json','Content-Type':'application/json'});return json.load(urllib.request.urlopen(req,timeout=30))
|
||||
def main():
|
||||
items=request('GET','/knowledge-bases')['data'];template=request('GET','/knowledge-bases/'+TEMPLATE_ID)['data'];wanted={'governance-internal-local':'治理平台本地对内解析队列','governance-external-local':'治理平台本地对外解析队列'}
|
||||
by_name={item['name']:item for item in items}
|
||||
for name,description in wanted.items():
|
||||
if name not in by_name:
|
||||
payload={'name':name,'type':'document','description':description,'is_temporary':False,'chunking_config':template['chunking_config'],'image_processing_config':template.get('image_processing_config',{'model_id':''}),'embedding_model_id':template['embedding_model_id'],'summary_model_id':template['summary_model_id'],'vlm_config':{'enabled':False},'extract_config':template.get('extract_config',{'enabled':False}),'storage_provider_config':template.get('storage_provider_config',{'provider':'oss'}),'indexing_strategy':template.get('indexing_strategy',{'vector_enabled':True,'keyword_enabled':True,'wiki_enabled':False,'graph_enabled':False})}
|
||||
created=request('POST','/knowledge-bases',payload)['data'];by_name[name]=created
|
||||
final=request('GET','/knowledge-bases')['data'];result=[]
|
||||
for name in wanted:
|
||||
item=next(value for value in final if value['name']==name)
|
||||
detail=request('GET','/knowledge-bases/'+item['id'])['data']
|
||||
assert detail['name']==name and detail['embedding_model_id']==template['embedding_model_id'] and detail['chunking_config']==template['chunking_config']
|
||||
result.append({'name':name,'id':detail['id'],'knowledge_count':detail.get('knowledge_count',0)})
|
||||
print(json.dumps(result,ensure_ascii=False))
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Enrich blank PP display metadata from existing read-only snapshots.
|
||||
|
||||
No network/source writes, uploads, visibility changes, or source versions.
|
||||
Default runs the scoped update in a transaction and rolls it back.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
ROOT = Path('/home/zheromiku/dobot-portal-sync/data')
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read_snapshot(relative):
|
||||
path = ROOT / relative
|
||||
for part in [ROOT, *path.parents, path]:
|
||||
if part.is_symlink():
|
||||
raise ValueError('snapshot symlinks are forbidden')
|
||||
if not path.is_file() or not path.resolve().is_relative_to(ROOT.resolve()):
|
||||
raise ValueError('invalid snapshot file')
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def label(value):
|
||||
if not isinstance(value, str):
|
||||
return ''
|
||||
value = value.strip()
|
||||
if len(value) > 512 or any(ord(c) < 32 for c in value) or '://' in value:
|
||||
return ''
|
||||
return value
|
||||
|
||||
|
||||
def build_rows(manifest, inventory):
|
||||
by_id = {str(item['id']): item for item in inventory['items']}
|
||||
result = {}
|
||||
for item in manifest['results']:
|
||||
members = [by_id[str(i)] for i in item.get('source_ids', []) if str(i) in by_id]
|
||||
titles = sorted({label(m.get('fileName')) for m in members} - {''})
|
||||
if not titles:
|
||||
filename = label(Path(item.get('local_path', '')).name)
|
||||
if filename:
|
||||
titles = [filename]
|
||||
if not titles:
|
||||
continue
|
||||
categories = {label((m.get('potalCategory') or {}).get('category')) for m in members}
|
||||
directory = next(iter(categories)) if len(categories) == 1 else ''
|
||||
# This is a PP category label, NOT a reconstructed ancestor hierarchy.
|
||||
if '/' in directory or '\\' in directory or directory in ('.', '..'):
|
||||
directory = ''
|
||||
source_hash = hashlib.sha256(item['url'].encode()).hexdigest()
|
||||
row = {'hash': source_hash, 'title': titles[0], 'directory': directory}
|
||||
if source_hash in result and result[source_hash] != row:
|
||||
raise ValueError('conflicting display metadata')
|
||||
result[source_hash] = row
|
||||
return sorted(result.values(), key=lambda x: x['hash'])
|
||||
|
||||
|
||||
def update_sql(rows, apply=False):
|
||||
payload = json.dumps(rows, ensure_ascii=False).replace("'", "''")
|
||||
return """BEGIN;
|
||||
SET LOCAL standard_conforming_strings = on;
|
||||
WITH metadata AS (
|
||||
SELECT * FROM jsonb_to_recordset('%s'::jsonb) AS m(hash text, title text, directory text)
|
||||
), changed AS (
|
||||
UPDATE source_items AS i SET title = m.title, normalized_path = m.directory
|
||||
FROM metadata m, source_connections c
|
||||
WHERE c.id = i.source_connection_id AND c.source_type = 'pp'
|
||||
AND i.canonical_uri_hash = m.hash AND i.title = ''
|
||||
RETURNING i.id
|
||||
) SELECT count(*) AS enriched FROM changed;
|
||||
%s;
|
||||
""" % (payload, 'COMMIT' if apply else 'ROLLBACK')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
args = parser.parse_args()
|
||||
rows = build_rows(read_snapshot('public-documents/manifest.json'), read_snapshot('pp-full/inventory-snapshot.json'))
|
||||
print(f'matched={len(rows)} with_category={sum(bool(r["directory"]) for r in rows)} mode={"apply" if args.apply else "dry-run"}', flush=True)
|
||||
result = subprocess.run(['docker', 'compose', 'exec', '-T', 'postgres', 'psql', '-X', '-v', 'ON_ERROR_STOP=1', '-U', 'governance', '-d', 'governance', '-At'], input=update_sql(rows, args.apply), text=True, cwd=REPO, capture_output=True)
|
||||
if result.returncode:
|
||||
raise SystemExit('metadata update failed; transaction rolled back (details withheld)')
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Refresh one source live, build a safe snapshot, and activate it.
|
||||
|
||||
Runs only inside the ordinary governance worker. It reads restricted credentials,
|
||||
uses allowlisted read-only APIs, writes temporary files under /tmp, and never
|
||||
downloads AList content or invokes source write/delete APIs.
|
||||
"""
|
||||
import argparse,csv,io,json,os,pathlib,subprocess,sys,tempfile
|
||||
from collections import deque
|
||||
sys.path.insert(0,'/app/scripts')
|
||||
from source_catalog_import import build_alist_source,build_dingtalk_sources,build_pp_live_source,catalog_sql,validate_source
|
||||
class RefreshBlocked(RuntimeError):pass
|
||||
|
||||
_source_name='';_last_directory=''
|
||||
|
||||
def emit(stage,progress,message,processed=0,total=0,current_directory='',scanned_directories=0,discovered_directories=0,scanned_files=0,estimated_total_files=0,total_is_estimate=False):
|
||||
global _last_directory
|
||||
if current_directory:_last_directory=current_directory
|
||||
payload={'stage':stage,'progress':progress,'message':message,'processed':processed,'total':total}
|
||||
if _source_name:payload['source_name']=_source_name
|
||||
if _last_directory:payload['current_directory']=_last_directory
|
||||
if scanned_directories:payload['scanned_directories']=scanned_directories
|
||||
if discovered_directories:payload['discovered_directories']=discovered_directories
|
||||
if scanned_files:payload['scanned_files']=scanned_files
|
||||
if estimated_total_files:payload['estimated_total_files']=estimated_total_files
|
||||
if total_is_estimate:payload['total_is_estimate']=True
|
||||
print(json.dumps(payload,ensure_ascii=False),flush=True)
|
||||
|
||||
def sanitize_directory(path):
|
||||
if not path:return ''
|
||||
cleaned=''.join('␉' if char=='\t' else char for char in str(path) if char.isprintable() or char=='\t')
|
||||
for forbidden in ('http://','https://','token','secret','password','authorization','bearer','cookie','/run/source-secrets','api-key','apikey'):
|
||||
if forbidden in cleaned.lower():return ''
|
||||
return cleaned[:1024]
|
||||
|
||||
def run_psql(sql):
|
||||
url=os.environ.get('DATABASE_URL','');
|
||||
if not url:raise RuntimeError('database unavailable')
|
||||
result=subprocess.run(['psql','-X','-v','ON_ERROR_STOP=1','-d',url,'-At'],input=sql,text=True,capture_output=True,timeout=180)
|
||||
if result.returncode:raise RuntimeError('catalog activation failed')
|
||||
|
||||
def refresh_pp():
|
||||
sys.path.insert(0,'/sources/pp');from download_scope_a import login,fetch_inventory
|
||||
emit('connecting',10,'正在登录PP资料下载中心')
|
||||
try:token=login('/run/source-secrets/pp_credentials.json')
|
||||
except Exception as error:raise RefreshBlocked('PP登录失败,请在来源链接更新凭据') from error
|
||||
emit('scanning',35,'正在实时读取PP公开资料清单')
|
||||
inventory=fetch_inventory(token)
|
||||
emit('building',75,'正在构建PP八类目录快照',len(inventory.get('items',[])),len(inventory.get('items',[])))
|
||||
manifest={}
|
||||
try:
|
||||
from pathlib import Path as _P
|
||||
manifest=json.loads(_P('/sources/pp/data/public-documents/manifest.json').read_text())
|
||||
except Exception:
|
||||
emit('building',78,'本地内容清单暂不可用,将记录为新资料待校验')
|
||||
return build_pp_live_source(inventory,manifest)
|
||||
|
||||
def refresh_dingtalk(name):
|
||||
root=pathlib.Path('/sources/dingtalk/data/dingtalk-internal/inventory-state.json');state=json.loads(root.read_text())
|
||||
matches=[(key,value) for key,value in state['roots'].items() if value.get('live_space_name')==name]
|
||||
if len(matches)!=1:raise RuntimeError('DingTalk source root missing')
|
||||
key,current=matches[0];output=pathlib.Path('/tmp')/('dingtalk-'+key+'-live.json')
|
||||
if output.exists():output.unlink()
|
||||
command=['python3','/sources/dingtalk/browser_dingtalk_scan.py','--capture','/run/source-secrets/dingtalk_capture.json','--output',str(output),'--root',f"{key}={current['configured_root_id']}",'--checkpoint-every','10']
|
||||
emit('connecting',8,'正在验证钉钉专用浏览器登录')
|
||||
process=subprocess.Popen(command,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True)
|
||||
assert process.stdout is not None
|
||||
last=10
|
||||
for line in process.stdout:
|
||||
if line.startswith('progress root='):
|
||||
values={part.split('=',1)[0]:part.split('=',1)[1] for part in line.strip().split() if '=' in part}
|
||||
done=int(values.get('folders','0'));pending=int(values.get('pending','0'));total=done+pending;files=int(values.get('files','0'))
|
||||
current_progress=10+(65*done//total if total else 65);last=max(last,current_progress)
|
||||
emit('scanning',last,f"正在扫描目录:已完成 {done} 个文件夹,待处理 {pending} 个",done,total,scanned_directories=done,discovered_directories=total,scanned_files=files,estimated_total_files=files,total_is_estimate=True)
|
||||
if process.wait()!=0 or not output.is_file():raise RefreshBlocked('钉钉登录会话已失效,请在来源链接重新扫码')
|
||||
payload=json.loads(output.read_text());sources=build_dingtalk_sources(payload);match=[source for source in sources if source['name']==name]
|
||||
if len(match)!=1:raise RuntimeError('DingTalk refreshed source missing')
|
||||
emit('building',80,'正在校验钉钉父子目录和文件数量')
|
||||
return match[0]
|
||||
|
||||
def refresh_alist(name):
|
||||
sys.path.insert(0,'/sources/alist');from alist_readonly_inventory import AListReadOnlyClient,join_path,read_connections
|
||||
connections=[item for item in read_connections(pathlib.Path('/run/source-secrets/alist_connections.xlsx'),require_token=False) if item['category']==name]
|
||||
if len(connections)!=1:raise RuntimeError('AList source configuration missing')
|
||||
token_path=pathlib.Path('/run/source-secrets/alist_api_token')
|
||||
try:
|
||||
token=token_path.read_text(encoding='utf-8').strip()
|
||||
if not token:raise ValueError('empty token')
|
||||
except (OSError,ValueError) as error:raise RefreshBlocked('AList API Token 未配置,请联系管理员更新来源凭据') from error
|
||||
connection=connections[0];client=AListReadOnlyClient(connection['base_url'],token);queue=deque(['/']);visited=set();directories=[];files=[];last=10
|
||||
emit('connecting',8,'正在验证AList直连只读凭据')
|
||||
while queue:
|
||||
path=queue.popleft()
|
||||
if path in visited:continue
|
||||
visited.add(path)
|
||||
try:items=client.list_directory(path)
|
||||
except Exception as error:raise RefreshBlocked('AList直连API访问失败,请检查只读Token或来源可用性') from error
|
||||
direct_dirs=direct_files=direct_bytes=0
|
||||
for item in items:
|
||||
item_name=str(item.get('name','')).strip()
|
||||
if not item_name or item_name in ('.','..'):continue
|
||||
child=join_path(path,item_name)
|
||||
if bool(item.get('is_dir')):
|
||||
direct_dirs+=1
|
||||
if child not in visited:queue.append(child)
|
||||
else:
|
||||
size=max(0,int(item.get('size') or 0));direct_files+=1;direct_bytes+=size
|
||||
files.append({'path':child,'parent_path':path,'name':item_name,'size':size,'modified':str(item.get('modified') or ''),'extension':pathlib.PurePosixPath(item_name).suffix.lower()})
|
||||
directories.append({'path':path,'parent_path':'' if path=='/' else str(pathlib.PurePosixPath(path).parent) or '/','depth':0 if path=='/' else path.count('/'),'status':'done','direct_directories':direct_dirs,'direct_files':direct_files,'direct_bytes':direct_bytes,'subtree_files':0,'subtree_bytes':0,'last_error':''})
|
||||
total=len(visited)+len(queue);progress=10+(65*len(visited)//total if total else 65);last=max(last,progress)
|
||||
if len(visited)%25==0 or not queue:emit('scanning',last,f"正在扫描目录:已完成 {len(visited)} 个文件夹,待处理 {len(queue)} 个",len(visited),total,current_directory=sanitize_directory(path),scanned_directories=len(visited),discovered_directories=total,scanned_files=len(files),estimated_total_files=len(files),total_is_estimate=True)
|
||||
dir_buf=io.StringIO();file_buf=io.StringIO();dw=csv.DictWriter(dir_buf,fieldnames=list(directories[0]));dw.writeheader();dw.writerows(directories);fw=csv.DictWriter(file_buf,fieldnames=['path','parent_path','name','size','modified','extension']);fw.writeheader();fw.writerows(files);dir_buf.seek(0);file_buf.seek(0)
|
||||
emit('building',80,'正在校验AList父子目录和文件数量',len(visited),len(visited))
|
||||
slug={'国内工业':'domestic-industrial','海外工业':'global-industrial','国内教育':'domestic-education','海外教育':'global-education'}[name]
|
||||
return build_alist_source(slug,name,dir_buf,file_buf)
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--connection-id',required=True);parser.add_argument('--source-type',choices=('pp','dingtalk','alist'),required=True);parser.add_argument('--source-name',required=True);args=parser.parse_args()
|
||||
global _source_name;_source_name=args.source_name
|
||||
try:
|
||||
if args.source_type=='pp':source=refresh_pp()
|
||||
elif args.source_type=='dingtalk':source=refresh_dingtalk(args.source_name)
|
||||
else:source=refresh_alist(args.source_name)
|
||||
validate_source(source);emit('activating',92,'正在写入不可变快照并切换当前版本',source['node_count'],source['node_count'])
|
||||
run_psql(catalog_sql(source,args.connection_id,apply=True));emit('completed',100,f"实时快照完成:{source['folder_count']-1} 个目录,{source['file_count']} 份文件",source['node_count'],source['node_count'])
|
||||
except RefreshBlocked as error:
|
||||
emit('failed',0,f"{error};旧快照仍然保留")
|
||||
return 1
|
||||
except Exception:
|
||||
emit('failed',0,'实时刷新发生内部错误;旧快照仍然保留')
|
||||
return 1
|
||||
return 0
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Build immutable, read-only directory snapshots from existing source inventories.
|
||||
|
||||
The pure builders never call source APIs. Database activation is added separately;
|
||||
raw source identifiers, URLs, host paths and credentials are not emitted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Iterable, TextIO
|
||||
|
||||
PP_ROOTS = ('工业产品','教育产品','具身产品','多足产品','解决方案','公司','软件','生态&配件')
|
||||
PP_PUBLIC_EXTENSIONS={'.pdf','.docx','.doc','.xlsx','.xls','.pptx','.ppt','.md','.txt','.csv','.json'}
|
||||
|
||||
|
||||
def digest(value:str)->str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def node_id(source_key:str,raw_key:str)->str:
|
||||
return 'sn-'+digest(source_key+'\0'+raw_key)[:32]
|
||||
|
||||
|
||||
def safe_text(value,maximum=1024)->str:
|
||||
if not isinstance(value,str): return ''
|
||||
symbols={'\n':'','\r':'␍','\t':'␉'}
|
||||
value=''.join(symbols.get(ch,'�' if ord(ch)<32 else ch) for ch in value).strip()
|
||||
if not value or len(value)>maximum: return ''
|
||||
return value
|
||||
|
||||
|
||||
def safe_relative_path(value:str)->str:
|
||||
value=value.strip('/')
|
||||
if len(value)>4096 or any(ord(ch)<32 for ch in value): raise ValueError('unsafe catalog path')
|
||||
if any(part in ('.','..') for part in value.split('/')): raise ValueError('unsafe catalog path')
|
||||
return value
|
||||
|
||||
|
||||
def normalized_source_path(value:str)->str:
|
||||
parts=[]
|
||||
for part in value.strip('/').split('/'):
|
||||
if part:
|
||||
cleaned=safe_text(part)
|
||||
if not cleaned:raise ValueError('source path segment is invalid')
|
||||
parts.append(cleaned)
|
||||
return safe_relative_path('/'.join(parts))
|
||||
|
||||
|
||||
def iso_from_millis(value)->str|None:
|
||||
if value in (None,''): return None
|
||||
return dt.datetime.fromtimestamp(int(value)/1000,dt.timezone.utc).isoformat().replace('+00:00','Z')
|
||||
|
||||
|
||||
def new_node(source_key:str,raw_key:str,parent_raw_key:str|None,node_type:str,name,relative_path:str,size_bytes=None,modified_at=None,sort_order=0,source_item_hash='',source_locator_hash='',content_sha256=''):
|
||||
name=safe_text(name)
|
||||
if not name: raise ValueError('catalog node has no safe name')
|
||||
if content_sha256:
|
||||
content_sha256=str(content_sha256).lower()
|
||||
if len(content_sha256)!=64 or any(c not in '0123456789abcdef' for c in content_sha256): raise ValueError('invalid content hash')
|
||||
return {
|
||||
'key':node_id(source_key,raw_key),
|
||||
'parent_key':None if parent_raw_key is None else node_id(source_key,parent_raw_key),
|
||||
'node_type':node_type,'name':name,'relative_path':safe_relative_path(relative_path),
|
||||
'source_locator_hash':source_locator_hash or digest(source_key+'\0'+raw_key),
|
||||
'source_item_hash':source_item_hash,
|
||||
'content_sha256':content_sha256 or None,
|
||||
'size_bytes':None if size_bytes in (None,'') else int(size_bytes),
|
||||
'modified_at':modified_at or None,'sort_order':int(sort_order),
|
||||
'direct_child_count':0,'subtree_file_count':0,'subtree_bytes':0,
|
||||
}
|
||||
|
||||
|
||||
def finalize_source(source:dict)->dict:
|
||||
nodes=source['nodes']; by_key={node['key']:node for node in nodes}
|
||||
children={key:[] for key in by_key}
|
||||
roots=[]
|
||||
for node in nodes:
|
||||
if node['parent_key'] is None: roots.append(node)
|
||||
elif node['parent_key'] in children: children[node['parent_key']].append(node)
|
||||
else: raise ValueError('catalog node has missing parent')
|
||||
if len(roots)!=1 or roots[0]['node_type']!='folder': raise ValueError('catalog requires one folder root')
|
||||
visiting=set(); memo={}
|
||||
def walk(node):
|
||||
if node['key'] in memo:return memo[node['key']]
|
||||
if node['key'] in visiting:raise ValueError('catalog cycle')
|
||||
visiting.add(node['key']); direct=children[node['key']]; node['direct_child_count']=len(direct)
|
||||
if node['node_type']=='file': identities={node['source_locator_hash']:node['size_bytes'] or 0}
|
||||
else:
|
||||
identities={}
|
||||
for child in direct:identities.update(walk(child))
|
||||
node['subtree_file_count']=len(identities);node['subtree_bytes']=sum(identities.values())
|
||||
visiting.remove(node['key']);memo[node['key']]=identities;return identities
|
||||
walk(roots[0])
|
||||
if len(memo)!=len(nodes):raise ValueError('catalog contains unreachable nodes')
|
||||
source['node_count']=len(nodes);source['folder_count']=sum(node['node_type']=='folder' for node in nodes);source['file_count']=roots[0]['subtree_file_count']
|
||||
canonical=json.dumps([{k:v for k,v in node.items() if k!='source_item_hash'} for node in sorted(nodes,key=lambda n:n['key'])],ensure_ascii=False,sort_keys=True,separators=(',',':'))
|
||||
source['snapshot_sha256']=digest(canonical)
|
||||
return source
|
||||
|
||||
|
||||
def build_dingtalk_sources(inventory:dict)->list[dict]:
|
||||
result=[]
|
||||
for source_key,root in sorted((inventory.get('roots') or {}).items()):
|
||||
source_id='dingtalk-'+source_key
|
||||
root_raw=safe_text(root.get('configured_root_id'))
|
||||
name=safe_text(root.get('live_space_name'))
|
||||
if not root_raw or not name:raise ValueError('invalid DingTalk root')
|
||||
raw_nodes={key:value for key,value in (root.get('nodes') or {}).items() if not value.get('is_in_trash') and not value.get('illegal')}
|
||||
path_cache={root_raw:''};visiting=set()
|
||||
def path_for(raw):
|
||||
if raw in path_cache:return path_cache[raw]
|
||||
if raw in visiting or raw not in raw_nodes:raise ValueError('DingTalk parent chain is incomplete')
|
||||
visiting.add(raw);item=raw_nodes[raw];parent=item.get('parent_dentry_uuid')
|
||||
parent_path=path_for(parent);name_part=safe_text(item.get('name'))
|
||||
if not name_part:raise ValueError('DingTalk node name is invalid')
|
||||
path_cache[raw]=safe_relative_path('/'.join(part for part in (parent_path,name_part) if part));visiting.remove(raw);return path_cache[raw]
|
||||
nodes=[new_node(source_id,root_raw,None,'folder',name,'',sort_order=-1)]
|
||||
for raw,item in raw_nodes.items():
|
||||
kind='folder' if item.get('dentry_type')=='folder' else 'file'
|
||||
parent=item.get('parent_dentry_uuid')
|
||||
if parent!=root_raw and parent not in raw_nodes:raise ValueError('DingTalk parent is missing')
|
||||
nodes.append(new_node(source_id,raw,parent,kind,item.get('name'),path_for(raw),item.get('file_size') if kind=='file' else None,iso_from_millis(item.get('content_updated_time') or item.get('updated_time'))))
|
||||
result.append(finalize_source({'source_key':source_id,'source_type':'dingtalk','name':name,'nodes':nodes}))
|
||||
return result
|
||||
|
||||
|
||||
def build_alist_source(source_key:str,name:str,directories:TextIO,files:TextIO)->dict:
|
||||
source_id='alist-'+source_key;name=safe_text(name)
|
||||
directory_rows=list(csv.DictReader(directories));file_rows=list(csv.DictReader(files))
|
||||
if not directory_rows or directory_rows[0].get('path')!='/':raise ValueError('AList root directory missing')
|
||||
nodes=[new_node(source_id,'dir:/',None,'folder',name,'',sort_order=-1)]
|
||||
directory_paths={'/'}
|
||||
for row in directory_rows[1:]:
|
||||
if row.get('status')!='done' or row.get('last_error'):raise ValueError('AList inventory is incomplete')
|
||||
path='/' + normalized_source_path(row.get('path',''))
|
||||
parent_raw=row.get('parent_path') or '/';parent='/' if parent_raw=='/' else '/'+normalized_source_path(parent_raw);directory_paths.add(path)
|
||||
nodes.append(new_node(source_id,'dir:'+path,'dir:'+parent,'folder',Path(path).name,path))
|
||||
for row in file_rows:
|
||||
path='/' + normalized_source_path(row.get('path',''));parent_raw=row.get('parent_path') or '/';parent='/' if parent_raw=='/' else '/'+normalized_source_path(parent_raw)
|
||||
if parent not in directory_paths:raise ValueError('AList file parent missing')
|
||||
nodes.append(new_node(source_id,'file:'+path,'dir:'+parent,'file',row.get('name'),path,row.get('size'),row.get('modified')))
|
||||
return finalize_source({'source_key':source_id,'source_type':'alist','name':name,'nodes':nodes})
|
||||
|
||||
|
||||
def build_pp_source(manifest:dict,inventory:dict)->dict:
|
||||
source_id='pp-download-center';name='PP资料下载中心';root_raw='root';nodes=[new_node(source_id,root_raw,None,'folder',name,'',sort_order=-1)]
|
||||
by_id={str(item.get('id')):item for item in inventory.get('items',[])}
|
||||
folders=set()
|
||||
for order,tag_type in enumerate(PP_ROOTS):
|
||||
raw='type:'+tag_type;folders.add(raw);nodes.append(new_node(source_id,raw,root_raw,'folder',tag_type,tag_type,sort_order=order))
|
||||
for result in manifest.get('results',[]):
|
||||
members=[by_id[str(value)] for value in result.get('source_ids',[]) if str(value) in by_id]
|
||||
titles=sorted({safe_text(member.get('fileName')) for member in members}-{''})
|
||||
if not titles:
|
||||
title=safe_text(Path(result.get('local_path','')).name)
|
||||
if not title:
|
||||
from urllib.parse import unquote,urlparse
|
||||
title=safe_text(Path(unquote(urlparse(str(result.get('url',''))).path)).name)
|
||||
titles=[title] if title else []
|
||||
if not titles:raise ValueError('PP public file title missing')
|
||||
locator=digest(str(result.get('url','')));size=result.get('bytes')
|
||||
if size is None:size=max((int(member.get('fileSize') or 0) for member in members),default=0)
|
||||
modified=max((safe_text(member.get('updateTime')) for member in members),default='') or None
|
||||
tags=set()
|
||||
for member in members:
|
||||
for tag in member.get('potalTags') or []:
|
||||
tag_type=safe_text(tag.get('tagType'));value=safe_text(tag.get('tag'))
|
||||
if tag_type in PP_ROOTS and value:tags.add((tag_type,value))
|
||||
content_sha=str(result.get('sha256','') or '').lower() if str(result.get('sha256','') or '') else ''
|
||||
if not tags:
|
||||
nodes.append(new_node(source_id,'file:'+locator,root_raw,'file',titles[0],titles[0],size,modified,source_item_hash=locator,source_locator_hash=locator,content_sha256=content_sha))
|
||||
for tag_type,value in sorted(tags,key=lambda pair:(PP_ROOTS.index(pair[0]),pair[1])):
|
||||
folder_raw='tag:'+tag_type+':'+value
|
||||
if folder_raw not in folders:
|
||||
folders.add(folder_raw);nodes.append(new_node(source_id,folder_raw,'type:'+tag_type,'folder',value,tag_type+'/'+value))
|
||||
alias_raw='file:'+locator+':'+tag_type+':'+value
|
||||
nodes.append(new_node(source_id,alias_raw,folder_raw,'file',titles[0],tag_type+'/'+value+'/'+titles[0],size,modified,source_item_hash=locator,source_locator_hash=locator,content_sha256=content_sha))
|
||||
return finalize_source({'source_key':source_id,'source_type':'pp','name':name,'nodes':nodes})
|
||||
|
||||
|
||||
def build_pp_live_source(inventory:dict,manifest:dict|None=None)->dict:
|
||||
# P1: the local download manifest is the authoritative content-hash evidence
|
||||
# for already-fetched bytes; live portal records absent from it stay null.
|
||||
hashes={}
|
||||
for entry in (manifest or {}).get('results',[]):
|
||||
url=str(entry.get('url') or '').strip()
|
||||
value=str(entry.get('sha256') or '').strip().lower()
|
||||
if url and len(value)==64 and all(c in '0123456789abcdef' for c in value):
|
||||
hashes[url]=value
|
||||
results=[]
|
||||
for item in inventory.get('items',[]):
|
||||
url=str(item.get('fileUrl') or '').strip()
|
||||
suffix=str(item.get('fileSuffix') or '').strip().lower()
|
||||
if suffix and not suffix.startswith('.'):suffix='.'+suffix
|
||||
if not suffix and url:
|
||||
from urllib.parse import unquote,urlparse
|
||||
suffix=Path(unquote(urlparse(url).path)).suffix.lower()
|
||||
if item.get('online')!=1 or (item.get('potalGroup') or {}).get('groupName')!='公开' or suffix not in PP_PUBLIC_EXTENSIONS:
|
||||
continue
|
||||
if not url:raise ValueError('public PP item has no source URL')
|
||||
results.append({'url':url,'source_ids':[str(item.get('id'))],'bytes':int(item.get('fileSize') or 0),'sha256':hashes.get(url,'')})
|
||||
merged={}
|
||||
for result in results:
|
||||
existing=merged.get(result['url'])
|
||||
if existing:
|
||||
existing['source_ids']=[value for value in dict.fromkeys(existing['source_ids']+result['source_ids'])]
|
||||
existing['bytes']=existing['bytes'] or result['bytes']
|
||||
existing['sha256']=existing.get('sha256') or result.get('sha256','')
|
||||
else:
|
||||
merged[result['url']]=dict(result)
|
||||
return build_pp_source({'results':list(merged.values())},inventory)
|
||||
|
||||
|
||||
def validate_source(source:dict)->None:
|
||||
if source.get('source_type') not in ('dingtalk','alist','pp') or not safe_text(source.get('name')):raise ValueError('invalid source')
|
||||
nodes=source.get('nodes') or [];keys=set()
|
||||
for node in nodes:
|
||||
if node['key'] in keys:raise ValueError('duplicate catalog node')
|
||||
keys.add(node['key'])
|
||||
if node['node_type'] not in ('folder','file') or not safe_text(node['name']) or node['relative_path'].startswith('/'):
|
||||
raise ValueError('invalid catalog node')
|
||||
safe_relative_path(node['relative_path'])
|
||||
if len(node['source_locator_hash'])!=64:raise ValueError('invalid source locator')
|
||||
if node.get('content_sha256') and (len(node['content_sha256'])!=64 or any(c not in '0123456789abcdef' for c in node['content_sha256'])):raise ValueError('invalid content sha256')
|
||||
if any(node['parent_key'] is not None and node['parent_key'] not in keys for node in nodes):raise ValueError('orphan catalog node')
|
||||
if source.get('node_count')!=len(nodes) or source.get('folder_count')!=sum(node['node_type']=='folder' for node in nodes):raise ValueError('invalid catalog counts')
|
||||
|
||||
|
||||
def load_json(path:Path,allowed_root:Path):
|
||||
resolved=path.resolve();root=allowed_root.resolve()
|
||||
if path.is_symlink() or not path.is_file() or not resolved.is_relative_to(root):raise ValueError('invalid snapshot path')
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def stable_connection_id(source_key:str)->str:
|
||||
return 'sc-'+digest(source_key)[:32]
|
||||
|
||||
|
||||
def sql_text(value:str)->str:
|
||||
return "'"+value.replace("'","''")+"'"
|
||||
|
||||
|
||||
def catalog_statements(source:dict,connection_id:str)->str:
|
||||
validate_source(source)
|
||||
snapshot_id='ss-'+digest(source['source_key']+'\0'+source['snapshot_sha256'])[:32]
|
||||
change_set_id='cs-'+digest(connection_id+'\0'+snapshot_id)[:32]
|
||||
payload=json.dumps(source['nodes'],ensure_ascii=False,separators=(',',':')).replace("'","''")
|
||||
health=json.dumps({'status':'degraded','reason':'snapshot_only'},separators=(',',':')).replace("'","''")
|
||||
return f"""
|
||||
INSERT INTO source_connections (id,source_type,name,base_url,secret_ref,enabled,schedule,last_health,created_at,updated_at)
|
||||
VALUES ({sql_text(connection_id)},{sql_text(source['source_type'])},{sql_text(source['name'])},'','',TRUE,'{{}}'::jsonb,'{health}'::jsonb,NOW(),NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name,updated_at=NOW();
|
||||
|
||||
INSERT INTO source_catalog_snapshots
|
||||
(id,source_connection_id,snapshot_sha256,source_label,node_count,folder_count,file_count,observed_at)
|
||||
VALUES ({sql_text(snapshot_id)},{sql_text(connection_id)},{sql_text(source['snapshot_sha256'])},{sql_text(source['name'])},{source['node_count']},{source['folder_count']},{source['file_count']},NOW())
|
||||
ON CONFLICT (source_connection_id,snapshot_sha256) DO NOTHING;
|
||||
|
||||
WITH current_snapshot AS (
|
||||
SELECT id FROM source_catalog_snapshots
|
||||
WHERE source_connection_id={sql_text(connection_id)} AND snapshot_sha256={sql_text(source['snapshot_sha256'])}
|
||||
), nodes AS (
|
||||
SELECT * FROM jsonb_to_recordset('{payload}'::jsonb) AS node(
|
||||
key text,parent_key text,node_type text,name text,relative_path text,
|
||||
source_locator_hash text,source_item_hash text,content_sha256 text,size_bytes bigint,
|
||||
modified_at text,sort_order integer,direct_child_count integer,
|
||||
subtree_file_count integer,subtree_bytes bigint
|
||||
)
|
||||
)
|
||||
INSERT INTO source_catalog_nodes
|
||||
(snapshot_id,node_id,parent_node_id,node_type,name,relative_path,source_locator_hash,
|
||||
source_item_id,size_bytes,modified_at,sort_order,direct_child_count,subtree_file_count,subtree_bytes,content_sha256)
|
||||
SELECT snapshot.id,node.key,NULLIF(node.parent_key,''),node.node_type,node.name,node.relative_path,
|
||||
node.source_locator_hash,item.id,node.size_bytes,NULLIF(node.modified_at,'')::timestamptz,
|
||||
node.sort_order,node.direct_child_count,node.subtree_file_count,node.subtree_bytes,node.content_sha256
|
||||
FROM current_snapshot snapshot CROSS JOIN nodes node
|
||||
LEFT JOIN source_items item ON item.source_connection_id={sql_text(connection_id)}
|
||||
AND node.source_item_hash<>'' AND item.canonical_uri_hash=node.source_item_hash
|
||||
ON CONFLICT (snapshot_id,node_id) DO NOTHING;
|
||||
|
||||
INSERT INTO source_catalog_change_sets(id,source_connection_id,from_snapshot_id,to_snapshot_id,created_at)
|
||||
SELECT {sql_text(change_set_id)},{sql_text(connection_id)},
|
||||
COALESCE(
|
||||
CASE WHEN head.snapshot_id<>target.id THEN head.snapshot_id END,
|
||||
(SELECT prior.id FROM source_catalog_snapshots prior
|
||||
WHERE prior.source_connection_id={sql_text(connection_id)} AND prior.id<>target.id
|
||||
AND prior.observed_at<=target.observed_at
|
||||
ORDER BY prior.observed_at DESC,prior.id DESC LIMIT 1)
|
||||
),
|
||||
target.id,NOW()
|
||||
FROM source_catalog_snapshots target
|
||||
LEFT JOIN source_catalog_heads head ON head.source_connection_id={sql_text(connection_id)}
|
||||
WHERE target.id={sql_text(snapshot_id)}
|
||||
ON CONFLICT (to_snapshot_id) DO NOTHING;
|
||||
|
||||
SELECT compare_catalog_change_set({sql_text(change_set_id)});
|
||||
|
||||
INSERT INTO source_catalog_heads (source_connection_id,snapshot_id,activated_at)
|
||||
SELECT {sql_text(connection_id)},id,NOW() FROM source_catalog_snapshots
|
||||
WHERE source_connection_id={sql_text(connection_id)} AND snapshot_sha256={sql_text(source['snapshot_sha256'])}
|
||||
ON CONFLICT (source_connection_id) DO UPDATE SET snapshot_id=EXCLUDED.snapshot_id,activated_at=EXCLUDED.activated_at;
|
||||
"""
|
||||
|
||||
|
||||
def catalog_sql(source:dict,connection_id:str,apply=False)->str:
|
||||
return "BEGIN;\nSET LOCAL standard_conforming_strings=on;\n"+catalog_statements(source,connection_id)+("COMMIT;\n" if apply else "ROLLBACK;\n")
|
||||
|
||||
|
||||
def checked_csv(path:Path,allowed_root:Path):
|
||||
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(allowed_root.resolve()):raise ValueError('invalid CSV snapshot path')
|
||||
return path.open(encoding='utf-8-sig',newline='')
|
||||
|
||||
|
||||
def build_local_sources()->list[dict]:
|
||||
dingtalk_root=Path('/home/zheromiku/dingtalk_kb_download/data/dingtalk-internal')
|
||||
pp_root=Path('/home/zheromiku/dobot-portal-sync/data')
|
||||
alist_root=Path('/home/zheromiku/tmp/alist/inventory')
|
||||
sources=build_dingtalk_sources(load_json(dingtalk_root/'inventory-state.json',dingtalk_root))
|
||||
for slug,label in (('domestic-industrial','国内工业'),('global-industrial','海外工业'),('domestic-education','国内教育'),('global-education','海外教育')):
|
||||
with checked_csv(alist_root/slug/'directories.csv',alist_root) as directories,checked_csv(alist_root/slug/'files.csv',alist_root) as files:
|
||||
sources.append(build_alist_source(slug,label,directories,files))
|
||||
sources.append(build_pp_source(load_json(pp_root/'public-documents/manifest.json',pp_root),load_json(pp_root/'pp-full/inventory-snapshot.json',pp_root)))
|
||||
return sources
|
||||
|
||||
|
||||
def psql(sql:str):
|
||||
command=['docker','compose','exec','-T','postgres','psql','-X','-v','ON_ERROR_STOP=1','-U','governance','-d','governance','-At']
|
||||
return subprocess.run(command,input=sql,text=True,cwd=Path(__file__).resolve().parents[1],capture_output=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--apply',action='store_true');args=parser.parse_args()
|
||||
sources=build_local_sources()
|
||||
lookup=psql("SELECT id FROM source_connections WHERE source_type='pp' ORDER BY created_at,id LIMIT 1;\n")
|
||||
if lookup.returncode:raise SystemExit('could not resolve PP connection (details withheld)')
|
||||
pp_connection=lookup.stdout.strip() or stable_connection_id('pp-download-center')
|
||||
statements=[]
|
||||
for source in sources:
|
||||
connection_id=pp_connection if source['source_type']=='pp' else stable_connection_id(source['source_key'])
|
||||
statements.append(catalog_statements(source,connection_id))
|
||||
sql="BEGIN;\nSET LOCAL standard_conforming_strings=on;\n"+'\n'.join(statements)+("\nCOMMIT;\n" if args.apply else "\nROLLBACK;\n")
|
||||
result=psql(sql)
|
||||
if result.returncode:raise SystemExit('catalog import failed; transaction rolled back (details withheld)')
|
||||
print('mode='+('apply' if args.apply else 'dry-run'))
|
||||
for source in sources:print(f"{source['source_type']} {source['name']}: nodes={source['node_count']} folders={source['folder_count']} files={source['file_count']}")
|
||||
print(f"total_sources={len(sources)} total_nodes={sum(s['node_count'] for s in sources)}")
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Stage the smallest verified PP public document as a routing canary."""
|
||||
import argparse,hashlib,json,os,pathlib,subprocess,tempfile
|
||||
SOURCE_ROOT=pathlib.Path('/home/zheromiku/dobot-portal-sync/data/public-documents');MANIFEST=SOURCE_ROOT/'manifest.json';REPO=pathlib.Path(__file__).resolve().parents[1];SPOOL=REPO/'spool'
|
||||
def psql(sql):return subprocess.run(['docker','compose','exec','-T','postgres','psql','-X','-v','ON_ERROR_STOP=1','-U','governance','-d','governance','-At'],input=sql,text=True,cwd=REPO,capture_output=True)
|
||||
def main():
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--apply',action='store_true');args=parser.parse_args();manifest=json.loads(MANIFEST.read_text());candidates=[]
|
||||
for item in manifest['results']:
|
||||
path=pathlib.Path(item.get('local_path',''))
|
||||
if path.is_file() and not path.is_symlink() and path.resolve().is_relative_to(SOURCE_ROOT.resolve()):candidates.append((int(item['bytes']),item,path))
|
||||
if not candidates:raise SystemExit('no safe PP canary')
|
||||
_,item,path=min(candidates,key=lambda value:value[0]);data=path.read_bytes();actual=hashlib.sha256(data).hexdigest()
|
||||
if len(data)!=item['bytes'] or actual!=item['sha256']:raise SystemExit('PP canary checksum mismatch')
|
||||
source_hash=hashlib.sha256(item['url'].encode()).hexdigest();relative=f"catalog-route/{actual}.txt";check=psql(f"SELECT content.sha256 FROM source_items item JOIN source_item_versions version ON version.source_item_id=item.id AND version.revision=item.revision JOIN content_objects content ON content.id=version.content_object_id WHERE item.canonical_uri_hash='{source_hash}';")
|
||||
if check.returncode or check.stdout.strip()!=actual:raise SystemExit('PP canary governance identity mismatch')
|
||||
if args.apply:
|
||||
destination=SPOOL/relative;destination.parent.mkdir(parents=True,exist_ok=True);fd,temp=tempfile.mkstemp(prefix='.canary-',dir=destination.parent)
|
||||
try:
|
||||
with os.fdopen(fd,'wb') as handle:handle.write(data);handle.flush();os.fsync(handle.fileno())
|
||||
os.chmod(temp,0o600);os.replace(temp,destination);os.chmod(destination,0o600)
|
||||
finally:
|
||||
if os.path.exists(temp):os.unlink(temp)
|
||||
update=psql(f"BEGIN; UPDATE content_objects content SET local_spool_ref='{relative}',validated_format='txt',updated_at=NOW() FROM source_item_versions version JOIN source_items item ON item.id=version.source_item_id WHERE version.content_object_id=content.id AND version.revision=item.revision AND item.canonical_uri_hash='{source_hash}' AND content.sha256='{actual}' RETURNING content.id; COMMIT;")
|
||||
if update.returncode or len([line for line in update.stdout.splitlines() if line.startswith('co-')])!=1:raise SystemExit('PP canary staging database update failed')
|
||||
print(json.dumps({'mode':'apply' if args.apply else 'dry-run','title':path.name,'bytes':len(data),'sha256':actual,'spool_ref':relative},ensure_ascii=False))
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,39 @@
|
||||
import unittest
|
||||
from import_display_metadata import build_rows, update_sql
|
||||
|
||||
|
||||
class MetadataTests(unittest.TestCase):
|
||||
def test_missing_inventory_uses_only_local_filename_not_server_path(self):
|
||||
rows = build_rows({'results': [{'url': 'https://example.invalid/a', 'source_ids': ['99'], 'local_path': '/private/files/说明.pdf'}]}, {'items': []})
|
||||
self.assertEqual(rows[0]['title'], '说明.pdf')
|
||||
self.assertEqual(rows[0]['directory'], '')
|
||||
self.assertNotIn('/private', str(rows))
|
||||
|
||||
def test_matches_only_manifest_source_ids_and_uses_real_category(self):
|
||||
manifest = {'results': [{'url': 'https://example.invalid/a', 'source_ids': ['1'], 'local_path': '/private/files/a.pdf'}]}
|
||||
inventory = {'items': [{'id': '1', 'fileName': '机器人手册', 'potalCategory': {'category': '用户手册'}}, {'id': '2', 'fileName': '未授权文档'}]}
|
||||
rows = build_rows(manifest, inventory)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]['title'], '机器人手册')
|
||||
self.assertEqual(rows[0]['directory'], '用户手册')
|
||||
self.assertNotIn('/private', str(rows))
|
||||
self.assertNotIn('https://', str(rows))
|
||||
|
||||
def test_ambiguous_categories_are_not_invented(self):
|
||||
rows = build_rows({'results': [{'url': 'https://example.invalid/a', 'source_ids': ['1','2']}]}, {'items': [
|
||||
{'id': '1', 'fileName': 'A', 'potalCategory': {'category': '甲'}},
|
||||
{'id': '2', 'fileName': 'A', 'potalCategory': {'category': '乙'}}]})
|
||||
self.assertEqual(rows[0]['directory'], '')
|
||||
|
||||
def test_sql_is_scoped_and_never_touches_publications_or_visibility(self):
|
||||
sql = update_sql([{'hash': 'a'*64, 'title': "a'b", 'directory': '公开'}])
|
||||
self.assertIn("source_type = 'pp'", sql)
|
||||
self.assertIn("i.title = ''", sql)
|
||||
self.assertIn('ROLLBACK', sql)
|
||||
self.assertNotIn('UPDATE test_publications', sql)
|
||||
self.assertNotIn('SET visibility', sql)
|
||||
self.assertNotIn('DELETE', sql)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,39 @@
|
||||
import io,json,unittest,contextlib
|
||||
import live_catalog_refresh as refresh
|
||||
|
||||
|
||||
class S4ProgressPayloadTests(unittest.TestCase):
|
||||
def collect(self, fn):
|
||||
buffer=io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
fn()
|
||||
return [json.loads(line) for line in buffer.getvalue().splitlines() if line.strip()]
|
||||
|
||||
def test_progress_lines_are_strict_fields_and_carry_scanned_counts(self):
|
||||
lines=self.collect(lambda: refresh.emit('scanning',40,'正在扫描目录:已完成 12 个文件夹,待处理 3 个',12,15,current_directory='/产品资料/CR系列',scanned_directories=12,discovered_directories=15,scanned_files=240,estimated_total_files=520,total_is_estimate=True))
|
||||
payload=lines[0]
|
||||
self.assertEqual(set(payload),{'stage','progress','message','processed','total','current_directory','scanned_directories','discovered_directories','scanned_files','estimated_total_files','total_is_estimate'})
|
||||
self.assertEqual(payload['scanned_files'],240)
|
||||
self.assertTrue(payload['total_is_estimate'])
|
||||
|
||||
def test_sanitizer_strips_control_chars_and_refuses_secrets_urls(self):
|
||||
self.assertEqual(refresh.sanitize_directory('/产品\x07资料'),'/产品资料')
|
||||
self.assertEqual(refresh.sanitize_directory('https://internal.example/x'),'')
|
||||
self.assertEqual(refresh.sanitize_directory('/run/source-secrets/pp'),'')
|
||||
self.assertEqual(refresh.sanitize_directory(''),'')
|
||||
self.assertEqual(len(refresh.sanitize_directory('目'*2000)),1024)
|
||||
|
||||
def test_source_name_only_appears_when_set_and_never_paths_with_credentials(self):
|
||||
refresh._source_name=''
|
||||
plain=self.collect(lambda: refresh.emit('building',80,'正在校验目录结构',5,5))
|
||||
self.assertNotIn('source_name',plain[0])
|
||||
refresh._source_name='国内工业'
|
||||
try:
|
||||
named=self.collect(lambda: refresh.emit('building',80,'正在校验目录结构',5,5))
|
||||
self.assertEqual(named[0]['source_name'],'国内工业')
|
||||
finally:
|
||||
refresh._source_name=''
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,142 @@
|
||||
import io
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from source_catalog_import import PP_ROOTS, build_alist_source, build_dingtalk_sources, build_pp_live_source, build_pp_source, catalog_sql, safe_text, validate_source
|
||||
|
||||
|
||||
class SourceCatalogImportTests(unittest.TestCase):
|
||||
def test_control_characters_are_visible_instead_of_breaking_the_file_list(self):
|
||||
self.assertEqual(safe_text('产品\n手册\t.pdf'),'产品手册␉.pdf')
|
||||
|
||||
def test_dingtalk_preserves_real_parent_child_nodes(self):
|
||||
inventory={
|
||||
'roots':{'company':{'configured_root_id':'root','live_space_name':'公司级技术支持知识库','nodes':{
|
||||
'folder':{'dentry_uuid':'folder','parent_dentry_uuid':'root','dentry_type':'folder','name':'01|产品资料','file_size':None,'updated_time':2},
|
||||
'file':{'dentry_uuid':'file','parent_dentry_uuid':'folder','dentry_type':'file','name':'手册.pdf','file_size':12,'updated_time':3},
|
||||
}}},
|
||||
}
|
||||
source=build_dingtalk_sources(inventory)[0]
|
||||
root=next(node for node in source['nodes'] if node['parent_key'] is None)
|
||||
folder=next(node for node in source['nodes'] if node['name']=='01|产品资料')
|
||||
file=next(node for node in source['nodes'] if node['name']=='手册.pdf')
|
||||
self.assertEqual(folder['parent_key'],root['key'])
|
||||
self.assertEqual(file['parent_key'],folder['key'])
|
||||
self.assertEqual(root['subtree_file_count'],1)
|
||||
validate_source(source)
|
||||
|
||||
def test_alist_uses_csv_parent_paths_and_recursive_counts(self):
|
||||
directories=io.StringIO('path,parent_path,depth,status,direct_directories,direct_files,direct_bytes,subtree_files,subtree_bytes,last_error\n/, ,0,done,1,0,0,1,10,\n/01 公司资料,/,1,done,0,1,10,1,10,\n')
|
||||
files=io.StringIO('path,parent_path,name,size,modified,extension\n/01 公司资料/a.pdf,/01 公司资料,a.pdf,10,2026-01-01T00:00:00Z,.pdf\n')
|
||||
source=build_alist_source('domestic-industrial','国内工业',directories,files)
|
||||
folder=next(node for node in source['nodes'] if node['name']=='01 公司资料')
|
||||
file=next(node for node in source['nodes'] if node['name']=='a.pdf')
|
||||
self.assertEqual(file['parent_key'],folder['key'])
|
||||
self.assertEqual(source['file_count'],1)
|
||||
validate_source(source)
|
||||
|
||||
def test_pp_uses_eight_real_tag_types_and_keeps_untagged_public_files_at_root(self):
|
||||
inventory={'items':[
|
||||
{'id':'1','fileName':'CRA手册','fileSize':10,'updateTime':'2026-01-01 00:00:00','potalTags':[{'tagType':'工业产品','tag':'CRA系列'},{'tagType':'解决方案','tag':'焊接'}]},
|
||||
{'id':'2','fileName':'未分类文档','fileSize':20,'updateTime':'2026-01-02 00:00:00','potalTags':[]},
|
||||
]}
|
||||
manifest={'results':[{'url':'https://safe.invalid/a','source_ids':['1']},{'url':'https://safe.invalid/b','source_ids':['2']}]}
|
||||
source=build_pp_source(manifest,inventory)
|
||||
root=next(node for node in source['nodes'] if node['parent_key'] is None)
|
||||
first=[node['name'] for node in source['nodes'] if node['parent_key']==root['key'] and node['node_type']=='folder']
|
||||
self.assertEqual(first,list(PP_ROOTS))
|
||||
self.assertTrue(any(node['name']=='未分类文档' and node['parent_key']==root['key'] for node in source['nodes']))
|
||||
self.assertEqual(root['subtree_file_count'],2)
|
||||
self.assertEqual(source['file_count'],2)
|
||||
validate_source(source)
|
||||
|
||||
def test_pp_records_real_content_hash_and_rejects_malformed_hash(self):
|
||||
sha='a'*64
|
||||
inventory={'items':[{'id':'1','fileName':'带哈希手册','fileSize':10,'updateTime':'2026-01-01 00:00:00','potalTags':[]}]}
|
||||
manifest={'results':[{'url':'https://safe.invalid/a','source_ids':['1'],'sha256':sha.upper()}]}
|
||||
source=build_pp_source(manifest,inventory)
|
||||
file=next(node for node in source['nodes'] if node['node_type']=='file')
|
||||
self.assertEqual(file['content_sha256'],sha)
|
||||
missing={'results':[{'url':'https://safe.invalid/b','source_ids':['1']}]}
|
||||
source2=build_pp_source(missing,inventory)
|
||||
self.assertIsNone(next(node for node in source2['nodes'] if node['node_type']=='file')['content_sha256'])
|
||||
bad={'results':[{'url':'https://safe.invalid/c','source_ids':['1'],'sha256':'zz'}]}
|
||||
with self.assertRaises(ValueError):
|
||||
build_pp_source(bad,inventory)
|
||||
|
||||
def test_pp_public_items_without_file_name_fall_back_to_the_source_url_title(self):
|
||||
inventory={'items':[
|
||||
{'id':'9','fileName':'','fileSize':10,'updateTime':'2026-01-03 00:00:00','potalTags':[{'tagType':'工业产品','tag':'CRA系列'}],'online':1,'fileSuffix':'pdf','fileUrl':'https://safe.invalid/path/CRA%20User%20Guide_V1.0_en.pdf','potalGroup':{'groupName':'公开'}},
|
||||
]}
|
||||
source=build_pp_live_source({'items':inventory['items']})
|
||||
file=next(node for node in source['nodes'] if node['node_type']=='file')
|
||||
self.assertEqual(file['name'],'CRA User Guide_V1.0_en.pdf')
|
||||
validate_source(source)
|
||||
|
||||
def test_live_pp_snapshot_derives_extension_from_url_when_file_suffix_is_empty(self):
|
||||
empty_suffix={'id':'1','online':1,'fileName':'无后缀手册','fileSize':10,'fileSuffix':'','fileUrl':'https://safe.invalid/path/%E6%89%8B%E5%86%8C.pdf','potalGroup':{'groupName':'公开'},'potalTags':[{'tagType':'工业产品','tag':'CRA系列'}]}
|
||||
media={'id':'2','online':1,'fileName':'培训视频','fileSize':10,'fileSuffix':'','fileUrl':'https://safe.invalid/video.mp4','potalGroup':{'groupName':'公开'},'potalTags':[]}
|
||||
source=build_pp_live_source({'items':[empty_suffix,media]})
|
||||
self.assertEqual(source['file_count'],1)
|
||||
file=next(node for node in source['nodes'] if node['node_type']=='file')
|
||||
self.assertEqual(file['name'],'无后缀手册')
|
||||
validate_source(source)
|
||||
|
||||
def test_live_pp_snapshot_merges_duplicate_public_urls_into_one_node(self):
|
||||
first={'id':'1','online':1,'fileName':'','fileSize':10,'fileSuffix':'pdf','fileUrl':'https://safe.invalid/dup.pdf','potalGroup':{'groupName':'公开'},'potalTags':[]}
|
||||
second={**first,'id':'2','fileName':'同一份手册'}
|
||||
source=build_pp_live_source({'items':[first,second]})
|
||||
self.assertEqual(source['file_count'],1)
|
||||
file=next(node for node in source['nodes'] if node['node_type']=='file')
|
||||
self.assertEqual(file['name'],'同一份手册')
|
||||
validate_source(source)
|
||||
|
||||
def test_live_pp_snapshot_backfills_content_hash_from_local_manifest(self):
|
||||
public={'id':'1','online':1,'fileName':'公开手册','fileSize':10,'fileSuffix':'pdf','fileUrl':'https://safe.invalid/a','potalGroup':{'groupName':'公开'},'potalTags':[]}
|
||||
fresh={**public,'id':'2','fileName':'新上传','fileUrl':'https://safe.invalid/b'}
|
||||
manifest={'results':[{'url':'https://safe.invalid/a','sha256':'ab'*32,'bytes':10}]}
|
||||
source=build_pp_live_source({'items':[public,fresh]},manifest)
|
||||
by_name={node['name']:node for node in source['nodes'] if node['node_type']=='file'}
|
||||
self.assertEqual(by_name['公开手册']['content_sha256'],'ab'*32)
|
||||
self.assertIsNone(by_name['新上传']['content_sha256'])
|
||||
validate_source(source)
|
||||
|
||||
def test_live_pp_snapshot_keeps_only_online_public_supported_documents(self):
|
||||
public={'id':'1','online':1,'fileName':'公开手册','fileSize':10,'fileSuffix':'pdf','fileUrl':'https://safe.invalid/a','potalGroup':{'groupName':'公开'},'potalTags':[{'tagType':'工业产品','tag':'CRA系列'}]}
|
||||
private={**public,'id':'2','fileName':'代理商资料','fileUrl':'https://safe.invalid/b','potalGroup':{'groupName':'代理商可查'}}
|
||||
offline={**public,'id':'3','fileName':'离线资料','fileUrl':'https://safe.invalid/c','online':0}
|
||||
source=build_pp_live_source({'items':[public,private,offline]})
|
||||
self.assertEqual(source['file_count'],1)
|
||||
self.assertTrue(any(node['name']=='公开手册' for node in source['nodes']))
|
||||
self.assertFalse(any(node['name'] in ('代理商资料','离线资料') for node in source['nodes']))
|
||||
|
||||
def test_validation_rejects_orphan_and_host_paths(self):
|
||||
source=build_pp_source({'results':[]},{'items':[]})
|
||||
source['nodes'][0]['relative_path']='/home/private'
|
||||
with self.assertRaises(ValueError): validate_source(source)
|
||||
|
||||
def test_catalog_sql_is_append_only_and_activates_an_immutable_snapshot(self):
|
||||
source=build_pp_source({'results':[]},{'items':[]})
|
||||
sql=catalog_sql(source,'sc-pp',apply=False)
|
||||
lowered=sql.lower()
|
||||
for required in ('insert into source_catalog_snapshots','insert into source_catalog_nodes','insert into source_catalog_change_sets','select compare_catalog_change_set(','insert into source_catalog_heads','rollback'):
|
||||
self.assertIn(required,lowered)
|
||||
# Classification actions live in the single-source DB function (P2/S8).
|
||||
migration=(Path(__file__).resolve().parents[1]/'internal/platform/postgres/migrations/000022_catalog_change_comparison.up.sql').read_text().lower()+(Path(__file__).resolve().parents[1]/'internal/platform/postgres/migrations/000023_complete_change_set.up.sql').read_text().lower()
|
||||
for action in ("'add'", "'update'", "'move_scope'", "'delete'", "'no_change'"):
|
||||
self.assertIn(action,migration)
|
||||
for reason in ("'content_hash_move'", "'content_hash_conflict'", "'path_change'", "'size_change'", "'source_missing'", "'scope_change'", "'move'", "'applying'", "identity_confidence"):
|
||||
self.assertIn(reason,(migration+Path(__file__).resolve().parents[1].joinpath('internal/platform/postgres/migrations/000021_source_identity_evidence.up.sql').read_text().lower()))
|
||||
self.assertLess(lowered.index('insert into source_catalog_change_sets'),lowered.index('insert into source_catalog_heads'))
|
||||
for forbidden in ('delete from','update source_items','drop table'):
|
||||
self.assertNotIn(forbidden,lowered)
|
||||
|
||||
def test_source_names_may_contain_literal_backslashes_without_becoming_host_paths(self):
|
||||
inventory={'roots':{'company':{'configured_root_id':'root','live_space_name':'知识库','nodes':{
|
||||
'folder':{'dentry_uuid':'folder','parent_dentry_uuid':'root','dentry_type':'folder','name':'协议\\SDK','updated_time':2},
|
||||
}}}}
|
||||
source=build_dingtalk_sources(inventory)[0]
|
||||
self.assertEqual(next(node for node in source['nodes'] if node['parent_key'] is not None)['name'],'协议\\SDK')
|
||||
validate_source(source)
|
||||
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
Reference in New Issue
Block a user