Просмотр исходного кода

feat(library): add product review insights and validated agent intake

pengzhikang 6 часов назад
Родитель
Сommit
bc5a009289

+ 54 - 0
docs/product-overview-20261106/发给运维001.md

@@ -0,0 +1,54 @@
+# 商品概况与Agent资料批次更新 · 2026-11-06
+
+本次新增商品概况的高频评价主题、代表原文、评分分布和历史价格统计,并提供Agent从真实商品/评价响应构建结构化暂存批次的工具。现有数据库可直接读取,无须重发98MB数据库;本包不执行采集或向生产导入新数据。
+
+## 1. 包与基线
+
+使用 `ksf-product-overview-20261106-r1.zip`,核外部delivery-receipt.json与包内FILES.sha256。RELEASE.json记录固定源码提交与Skill指纹。基线为此前完整部署提交f4bdaa70a1f9ceac7c44f79a32d7454143875fd5;若实际文件另有热修,停止并报告差异,保留现网改动。
+
+解包至站点外私有目录。备份与回执放站点、Skill目录之外。不得把payload/server、skills、来源包或数据库放进公开静态目录。
+
+## 2. 私有资料库服务
+
+先确认pm2 `ksf-library-api` 的实际脚本目录和运行用户,不以网关4181或静态站点目录推算后端位置。按deploy/backend-files.json核实旧指纹,备份后更新该目录内的4项:
+
+- server/voc-library/board_library.py(更新),product_insights.py(新增):商品detail返回record.overview。
+- server/voc-library/update_library.py(更新),agent_batch.py(新增):保留可选主题标注的新代际合并及暂存构建工具;本轮不运行--apply、不切数据库指针。
+
+相对路径以实际项目根为准。既有voc_db.py、retrieval.py、schema.sql、taxonomy.json须与deploy/dependencies.json对应;不符先报告。服务每次请求启动Python读取模块,按现有实现更新Python文件后无需重启网关;若实际运行方式不同,以核实结果决定。保持4180/4181服务、nginx精确转发与私有凭据原样。
+
+先以既有服务端受控方式只读调用商品detail,核record.overview.schema=ksf.product-overview.v1、productId一致。不要在聊天或浏览器中传服务token。
+
+## 3. 两个前端文件
+
+`node scripts/publish-board.mjs --target-dir /实际/静态站点目录` 默认只读;核changes恰为src/core/library.js与src/pages/library-product-detail.js,首页指纹符合清单。符合后同命令加--apply一次并保留备份。本工具只管理这两文件,已是同版时无变更。
+
+## 4. 王刚001研究Skill
+
+包内两份Skill供基线对齐,工作坊Skill不变;研究Skill旧9/9→新13/13。
+
+`python3 -B install-query-skills.py --package /实际/解包目录 --data-root /opt/data --check`
+
+预期filesChecked=25,changed恰6项,全部属于ksf-voc-library:SKILL.md、FILES.sha256、新references/product-overview-and-intake.md及scripts/{product_insights,agent_batch,update_library}.py。没有工作坊变更。检查符合后加--apply一次;保留备份,回读研究13/13、工作坊10/10及清单自身指纹。
+
+未知旧版、并发修改先停。保留credential-binding.json、员工/Agent档案、会话索引、数据库与指针。未真实读到新章节前discoveryVerified=false,不能把文件安装等同Agent加载。Agent本轮不需要新建会话、报告或尝试付费采集。
+
+## 5. 分层验收
+
+1. 包、后端模块、前端两文件、Skill分别核验。
+2. 生产只读detail:jd:100005702440在本代际应为collected=19、eligible=18、distinctTexts=17;如果已更新代际,按实际回执核数,不拿旧预期凑数。评分按原体系、价格按口径分组,主题可重叠。
+3. 生产浏览器打开/library/products/jd%3A100005702440:商品概况显示统计和主题,默认展开首项;点击“查看原评价”仅返回本商品该条评价,刷新仍保持;“查看全部评价”恢复本商品评价范围。
+4. 桌面及手机检查无撑宽;无评论商品展示暂无,不借用其他商品。回归/library筛选、排序、消费者评价、政策与其他原有页面。
+5. Agent下一条真实消息实际阅读“商品评价概况与新增资料”及引用文档,复述原文映射、商品精确关联、统计不手填、先暂存后授权发布规则即可;未提供新原始响应时不构造生产批次。
+
+本地已做浏览器与来源回读验证,生产browserVerified仍须由本次实际验收记录;不沿用上一版的生产浏览器结果。回报当前generation、SQL读写与文件变更范围,查询失败不作零命中。
+
+## 后续新增资料
+
+Agent先查库,缺口用实际已授权工具补采,保留原响应→JSON Pointer字段映射→agent_batch.py只读预检→--apply构建全新私有staging.sqlite及.source.zip→contract/verify/同范围查询和source回读→维护流程合并新代际。本轮只是交付入口,未开通采集器、付费权限或自动生产写入。当前批次工具支持商品与评价;政策/社媒沿用既有归档导入。
+
+现在生产看板使用独立library.sqlite,不能直接假设共享registry已启用。将来正式发布批次前,维护人须按实际部署确定同一registry的发布与Agent读取绑定,或使用既有受控快照交付;未对齐代际前,不声称Agent与生产看板自动同步。
+
+## 回退
+
+按本次备份回退两前端和四后端项,仅回退仍匹配本次新指纹的文件,新模块仅在仍为本版时隔离。Skill恢复备份中的原SKILL.md与FILES.sha256,并隔离本次新增4文件。保留并发改动、数据库、原件、凭据、身份及会话。未执行数据库发布,因此无数据库回退步骤。

+ 15 - 0
docs/product-overview-20261106/本地验证.md

@@ -0,0 +1,15 @@
+# 本地验证 · 商品概况与Agent资料批次
+
+2026-11-06,本地统一预览http://127.0.0.1:4181,使用真实研究库快照。
+
+- Node:test/*.test.js 124项、server/test/*.test.mjs 67项通过;scripts/check.mjs通过。
+- Python:server/voc-library/test_*.py 73项通过,包含来源映射预检零写入、暂存构建、批次合并、主题原文/指纹、去重及商品隔离测试。
+- Skill:研究13/13、工作坊10/10;skill-creator quick_validate通过。安装器与两文件发布器将随固定源码包再次在旧基线副本执行dry/apply/no-op测试。
+- scripts/verify-product-overview-browser.py通过,1440/1024/390/320宽度无内容撑宽;主题排序、引文精确跳转、刷新保留、恢复全部评价、无评价商品空态均通过。
+- scripts/verify-library-browser.py资料库整体回归通过,含平台/品牌/品类交集、排序、商品关系、评价分页、政策原文、错误恢复与手机布局;控制台异常0。
+
+示例商品jd:100005702440:19条收录、18条可分析、17条去重文本。口味与调味13条去重文本、14条原记录,份额76.5%;不作消费者占比或好评率。评分按原体系统计18条;2次可用数字价格观察按搜索展示价分组,其他未知金额不填0。
+
+最大评价样本商品收录1000条,本机完整detail约0.054秒,仅作本机观察。原数据库99MB文件SHA未改变:944e4bb3a5d65dd897ea7c912e1af001aa845db1ba1f968a7b0ee653dc60fa50;代际7e0df42395d9d2ea39dd5b537d17ae35ac910cc29721ed92a680b7fe6b25c92c。
+
+只读取得的生产首页及本次两前端文件仍匹配已部署旧基线;新功能尚未部署到生产。生产浏览器、目标Agent安装/加载与真实新增批次发布未在本轮验证,不继承旧版验收结果。截图和机器回执位于本机忽略目录work/library-preview,不含于公开静态发布。

+ 115 - 0
scripts/package-product-overview.py

@@ -0,0 +1,115 @@
+"""Freeze the committed product overview patch, without private databases or credentials."""
+import argparse
+import hashlib
+import importlib.util
+import io
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+BASE = 'f4bdaa70a1f9ceac7c44f79a32d7454143875fd5'
+NAME = 'ksf-product-overview-20261106-r1'
+FRONT = ['src/core/library.js', 'src/pages/library-product-detail.js']
+BACK = ['server/voc-library/' + n for n in ('board_library.py', 'product_insights.py', 'update_library.py', 'agent_batch.py')]
+DEPS = ['server/voc-library/' + n for n in ('voc_db.py', 'retrieval.py', 'schema.sql', 'taxonomy.json')]
+sha = lambda b: hashlib.sha256(b).hexdigest()
+norm = lambda b: sha(b.replace(b'\r\n', b'\n'))
+
+def main():
+    p = argparse.ArgumentParser(description=__doc__)
+    p.add_argument('--commit', default='HEAD'); p.add_argument('--homepage-file', required=True)
+    args = p.parse_args()
+    commit = subprocess.check_output(['git', 'rev-parse', args.commit + '^{commit}'], cwd=ROOT).decode().strip()
+    subprocess.run(['git', 'merge-base', '--is-ancestor', BASE, commit], cwd=ROOT, check=True)
+    frozen = zipfile.ZipFile(io.BytesIO(subprocess.check_output(['git', 'archive', '--format=zip', commit], cwd=ROOT)))
+    old = zipfile.ZipFile(io.BytesIO(subprocess.check_output(['git', 'archive', '--format=zip', BASE], cwd=ROOT)))
+    out = ROOT / 'artifacts/商品评价概况与入库更新-20261106'
+    package = ROOT / 'work' / NAME / 'package'
+    if package.exists() or (out / (NAME + '.zip')).exists(): raise ValueError('RELEASE_ALREADY_EXISTS')
+    package.mkdir(parents=True)
+    def write(name, body):
+        dest = package / name; dest.parent.mkdir(parents=True, exist_ok=True); dest.write_bytes(body)
+    def js(name, value): write(name, (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode())
+    for name in FRONT + BACK: write('payload/' + name, frozen.read(name))
+    for name in frozen.namelist():
+        if name.startswith('skills/') and not name.endswith('/'): write(name, frozen.read(name))
+    write('install-query-skills.py', frozen.read('scripts/install-query-skills.py'))
+    baselines = {name: [{p.split('/', 2)[2]: sha(old.read(p)) for p in old.namelist() if p.startswith('skills/' + name + '/') and not p.endswith('/')}]
+                 for name in ('ksf-workshop-runtime', 'ksf-voc-library')}
+    js('baselines.json', baselines)
+    def entry(name):
+        prior = old.read(name) if name in old.namelist() else None
+        return dict(path=name, sha256=sha(frozen.read(name)), priorNormalizedSha256=[norm(prior)] if prior else [], allowMissing=prior is None)
+    manifest = dict(version='ksf.product-overview.publish.v1', release=NAME,
+        homepageSha256=json.loads(old.read('deploy/board-files-20261106.json'))['homepageSha256'], files=[entry(n) for n in FRONT])
+    js('deploy/board-files.json', manifest); js('deploy/backend-files.json', dict(files=[entry(n) for n in BACK]))
+    js('deploy/dependencies.json', dict(files=[dict(path=n, normalizedSha256=norm(frozen.read(n))) for n in DEPS]))
+    publisher = frozen.read('scripts/publish-opportunity-ui.mjs').decode()
+    start = publisher.index('export const FILES=Object.freeze(['); end = publisher.index(']);', start) + 3
+    publisher = publisher[:start] + 'export const FILES=Object.freeze(' + json.dumps(FRONT) + ');' + publisher[end:]
+    publisher = publisher.replace('ksf.opportunity-ui.publish.v1', manifest['version']).replace('ksf.opportunity-ui.20261103.v1', NAME)
+    publisher = publisher.replace('filesChecked:6', 'filesChecked:FILES.length').replace('ksf-opportunity-ui', 'ksf-product-overview')
+    publisher = publisher.replace("sourceDir:root,manifestPath:path.join(root,'deploy/opportunity-ui-files-20261103.json')", "sourceDir:path.join(root,'payload'),manifestPath:path.join(root,'deploy/board-files.json')")
+    write('scripts/publish-board.mjs', publisher.encode())
+    write('发给运维001.md', frozen.read('docs/product-overview-20261106/发给运维001.md'))
+    skills = {}
+    for name in baselines:
+        folder = package / 'skills' / name
+        lines = (folder / 'FILES.sha256').read_text(encoding='utf-8').splitlines()
+        for line in lines:
+            h, rel = line.split('  ', 1); assert sha((folder / rel).read_bytes()) == h
+        skills[name] = dict(entries=len(lines), manifestSha256=sha((folder / 'FILES.sha256').read_bytes()))
+    # Exercise the actual delivered installer against both frozen baseline skills.
+    spec = importlib.util.spec_from_file_location('overview_skill_installer', package / 'install-query-skills.py')
+    installer = importlib.util.module_from_spec(spec); spec.loader.exec_module(installer)
+    with tempfile.TemporaryDirectory(prefix='installer-check-', dir=package.parent) as td:
+        data = Path(td)
+        for name in baselines:
+            for rel in baselines[name][0]:
+                target = data / 'skills' / name / rel; target.parent.mkdir(parents=True, exist_ok=True)
+                target.write_bytes(old.read('skills/' + name + '/' + rel))
+        plan = installer.install(package, data)
+        assert plan['filesChecked'] == 25 and len(plan['changed']) == 6
+        assert all(n.startswith('ksf-voc-library/') for n in plan['changed'])
+        assert installer.install(package, data, True)['filesystemVerified']
+        assert not installer.install(package, data, True)['applied']
+    # Check the two-file publisher plan/apply/no-op against the actual baseline.
+    with tempfile.TemporaryDirectory(prefix='static-check-', dir=package.parent) as td:
+        target = Path(td)
+        for name in ['index.html', *FRONT]:
+            dest = target / name; dest.parent.mkdir(parents=True, exist_ok=True)
+            dest.write_bytes(old.read(name))
+        # The maintained homepage is external to Git's local preview page.
+        index = Path(args.homepage_file).read_bytes()
+        assert sha(index) == manifest['homepageSha256'], 'HOMEPAGE_MISMATCH'
+        (target / 'index.html').write_bytes(index)
+        cli = ['node', str(package / 'scripts/publish-board.mjs'), '--target-dir', str(target)]
+        result = json.loads(subprocess.check_output(cli, cwd=ROOT))
+        assert result['changes'] == FRONT
+        assert json.loads(subprocess.check_output([*cli, '--apply'], cwd=ROOT))['filesystemVerified']
+        assert not json.loads(subprocess.check_output([*cli, '--apply'], cwd=ROOT))['changes']
+    js('RELEASE.json', dict(release=NAME, sourceCommit=commit, baselineCommit=BASE, skills=skills,
+        staticFiles=2, backendFiles=4, databaseIncluded=False, databaseMigrationRequired=False,
+        credentialsIncluded=False, productionInstalled=False, productionBrowserVerified=False,
+        installerCheck=True, publisherCheck=True))
+    files = sorted(q for q in package.rglob('*') if q.is_file())
+    assert not any(q.suffix in ('.sqlite', '.db', '.key', '.pyc') or q.name.startswith('.env') for q in files)
+    write('FILES.sha256', ''.join(sha(q.read_bytes()) + '  ' + q.relative_to(package).as_posix() + '\n' for q in files).encode())
+    out.mkdir(parents=True, exist_ok=True); archive = out / (NAME + '.zip')
+    with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED, compresslevel=9) as z:
+        for q in [*files, package / 'FILES.sha256']: z.write(q, q.relative_to(package).as_posix())
+    with zipfile.ZipFile(archive) as z:
+        assert z.testzip() is None
+        for line in z.read('FILES.sha256').decode().splitlines():
+            h, name = line.split('  ', 1); assert sha(z.read(name)) == h
+    receipt = dict(ok=True, filename=archive.name, bytes=archive.stat().st_size, sha256=sha(archive.read_bytes()),
+        entries=len(files) + 1, filesVerified=len(files), sourceCommit=commit, productionInstalled=False)
+    (out / 'delivery-receipt.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    shutil.copyfile(package / '发给运维001.md', out / '发给运维001.md')
+    print(json.dumps(receipt, ensure_ascii=False, indent=2))
+
+if __name__ == '__main__': main()

+ 2 - 1
scripts/verify-library-browser.py

@@ -158,7 +158,8 @@ try:
     product_ready()
     assert product_action('return p.product.id;')==action('return v._detail.record.id;')
     assert product_action('return p.renderRoot.querySelectorAll(".product-resources button").length;')==4
-    assert action('return v.renderRoot.querySelector("#source").getBoundingClientRect().bottom;')<700
+    # The overview now includes review themes; source actions remain in the compact header.
+    assert action('return v.renderRoot.querySelector(".detail-actions").getBoundingClientRect().bottom;')<300
     product_tab('prices')
     assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
     assert action('return v.routeQuery.brand;')=='ksf'

+ 103 - 0
scripts/verify-product-overview-browser.py

@@ -0,0 +1,103 @@
+"""Verify product insights, exact quote navigation and responsive layout on real data."""
+import argparse
+import base64
+import json
+from pathlib import Path
+import subprocess
+import tempfile
+import time
+from urllib.request import urlopen
+import websocket
+
+ROOT = Path(__file__).resolve().parents[1]
+WORK = ROOT / 'work/library-preview'
+WORK.mkdir(parents=True, exist_ok=True)
+p = argparse.ArgumentParser(description=__doc__)
+p.add_argument('--url', default='http://127.0.0.1:4181')
+args = p.parse_args()
+profile = Path(tempfile.mkdtemp(prefix='overview-browser-', dir=WORK))
+process = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
+    '--disable-gpu', '--remote-debugging-port=0', '--disable-background-networking', '--disable-extensions',
+    '--no-first-run', '--user-data-dir=' + str(profile), 'about:blank'], stdout=subprocess.DEVNULL,
+    stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
+ws = None
+serial = 0
+events = []
+try:
+    end = time.monotonic() + 20
+    while not (profile / 'DevToolsActivePort').exists():
+        assert time.monotonic() < end and process.poll() is None
+        time.sleep(.1)
+    port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
+    page = next(x for x in json.load(urlopen('http://127.0.0.1:' + port + '/json/list')) if x['type'] == 'page')
+    ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=35, suppress_origin=True, http_no_proxy=['127.0.0.1'])
+    def call(method, params=None):
+        global serial
+        serial += 1
+        ws.send(json.dumps(dict(id=serial, method=method, params=params or {})))
+        while True:
+            r = json.loads(ws.recv())
+            if r.get('id') == serial:
+                assert 'error' not in r, r
+                return r.get('result', {})
+            events.append(r)
+    def ev(expression, promise=False):
+        r = call('Runtime.evaluate', dict(expression=expression, returnByValue=True, awaitPromise=promise))
+        assert 'exceptionDetails' not in r, r
+        return r['result'].get('value')
+    def wait(condition):
+        ev('new Promise((resolve,reject)=>{const end=Date.now()+25000;const tick=()=>{try{if(' + condition + '){resolve(true);return}}catch{}if(Date.now()>end)reject(new Error("timeout"));else setTimeout(tick,60)};tick()})', True)
+    V = 'document.querySelector("kf-app")?.shadowRoot?.querySelector("view-library")'
+    P = V + '?.renderRoot.querySelector("library-product-detail")'
+    def action(body): return ev('(()=>{const p=' + P + ';' + body + '})()')
+    def ready(): wait(P + '?.product?.overview && !' + P + '._loading')
+    def shot(name):
+        data = call('Page.captureScreenshot', dict(format='png', captureBeyondViewport=False))
+        (WORK / (name + '.png')).write_bytes(base64.b64decode(data['data']))
+    call('Page.enable'); call('Runtime.enable')
+    call('Emulation.setDeviceMetricsOverride', dict(width=1440, height=1100, deviceScaleFactor=1, mobile=False))
+    url = args.url.rstrip('/') + '/library/products/jd%3A100005702440'
+    call('Page.navigate', dict(url=url)); ready()
+    stats = action('return p.product.overview.reviews;')
+    assert (stats['collected'], stats['eligible'], stats['distinctTexts']) == (19, 18, 17), stats
+    themes = action('return p.product.overview.themes;')
+    assert [t['distinctTextCount'] for t in themes] == sorted([t['distinctTextCount'] for t in themes], reverse=True)
+    assert action('return p.renderRoot.querySelector("details.theme-item").open;')
+    assert action('return Array.from(p.renderRoot.querySelectorAll(".overview-metrics strong")).map(x=>+x.textContent);')[:3] == [19, 18, 17]
+    shot('product-overview-desktop')
+    selected = themes[0]['examples'][0]['reviewId']
+    action('p.renderRoot.querySelector(".theme-quotes .text-button").click();')
+    wait(P + '?.panel==="reviews" && !' + P + '._loading && ' + P + '._data')
+    assert action('return p._data.totalMatched;') == 1
+    assert action('return p._data.records[0].record_id;') == selected
+    call('Page.reload'); ready()
+    assert action('return p._data.totalMatched;') == 1
+    action('p.renderRoot.querySelector(".review-controls .text-button").click();')
+    wait('!' + P + '.query.relatedReviewId && !' + P + '._loading && ' + P + '._data?.totalMatched===18')
+    action('p.renderRoot.getElementById("tab-overview").click();'); ready()
+    layouts = []
+    for width in (1440, 1024, 390, 320):
+        call('Emulation.setDeviceMetricsOverride', dict(width=width, height=1100, deviceScaleFactor=1, mobile=width < 600))
+        ev('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))', True)
+        if width == 390: shot('product-overview-mobile')
+        assert ev('document.documentElement.scrollWidth<=innerWidth'), width
+        sizes = action('return Array.from(p.renderRoot.querySelectorAll(".insights-grid,.overview-metrics,.theme-item,.aggregate-panel")).map(x=>({class:x.className,scroll:x.scrollWidth,width:x.clientWidth}));')
+        assert all(x['scroll'] <= x['width'] + 1 for x in sizes), (width, sizes)
+        layouts.append(dict(width=width, metricColumns=action('return getComputedStyle(p.renderRoot.querySelector(".overview-metrics")).gridTemplateColumns.split(" ").length;')))
+    call('Emulation.setDeviceMetricsOverride', dict(width=1440, height=1100, deviceScaleFactor=1, mobile=False))
+    call('Page.navigate', dict(url=args.url.rstrip('/') + '/library?sort=reviews_asc'))
+    wait(V + '?._data?.records?.length && !' + V + '._loading')
+    empty = ev(V + '._data.records.find(r=>!r.relatedCounts.reviews).record_id')
+    call('Page.navigate', dict(url=args.url.rstrip('/') + '/library/products/' + empty.replace(':', '%3A'))); ready()
+    assert action('return p.product.overview.reviews.collected;') == 0
+    assert action('return p.renderRoot.querySelector(".insight-empty").textContent.includes("暂未收录评价");')
+    assert not [e for e in events if e.get('method') == 'Runtime.exceptionThrown']
+    result = dict(ok=True, stats=stats, layouts=layouts, exactQuoteNavigation=True, refreshPreservesQuote=True,
+                  emptyProductVerified=True, browserVerified=True, productionBrowserVerified=False, databaseWrites=0)
+    (WORK / 'product-overview-validation.json').write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+finally:
+    if ws: ws.close()
+    process.terminate()
+    try: process.wait(timeout=8)
+    except subprocess.TimeoutExpired: process.kill()

+ 203 - 0
server/voc-library/agent_batch.py

@@ -0,0 +1,203 @@
+"""Validate Agent source mappings, build a new private batch, never modify the active library.
+
+Only product/review intake is supported here. Existing archive ingestion remains
+the route for social and policy material. No network, credentials or paid calls.
+"""
+import argparse
+import collections
+import hashlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+import sqlite3
+import sys
+import tempfile
+import zipfile
+import voc_db
+from product_insights import ANNOTATION_DDL, THEMES, text_hash
+
+SCHEMA = 'ksf.voc-agent-batch.v1'
+PRODUCT_FIELDS = {'itemId': 'item_id', 'title': 'title', 'url': 'url', 'shopId': 'shop_id', 'shopName': 'shop_name',
+                  'price': 'price', 'priceBasis': 'price_type', 'sourceTime': 'source_time'}
+REVIEW_FIELDS = {'itemId': 'item_id', 'reviewId': 'review_id', 'content': 'content', 'appendContent': 'append_content',
+                 'rating': 'rating', 'ratingScale': 'rating_scale', 'reviewDate': 'review_date', 'purchasedSku': 'sku',
+                 'isDefault': 'is_default_text', 'sourceTime': 'source_time'}
+SOURCE_FIELDS = {'id', 'path', 'sha256', 'platform', 'marketplace', 'requestItemId', 'observedAt', 'collectedAt', 'url'}
+
+def require(value, code):
+    voc_db.require(value, code)
+
+def digest(p):
+    with Path(p).open('rb') as f: return hashlib.file_digest(f, 'sha256').hexdigest()
+
+def pointer(value, path):
+    require(isinstance(path, str) and (not path or path.startswith('/')), 'INVALID_JSON_POINTER')
+    for token in path.split('/')[1:]:
+        token = token.replace('~1', '/').replace('~0', '~')
+        if isinstance(value, list):
+            require(token.isdigit() and int(token) < len(value), 'SOURCE_POINTER_NOT_FOUND')
+            value = value[int(token)]
+        else:
+            require(isinstance(value, dict) and token in value, 'SOURCE_POINTER_NOT_FOUND')
+            value = value[token]
+    return value
+
+def identifier(value):
+    require(isinstance(value, str) and value.strip() == value and 0 < len(value) <= 200 and not any(ord(c) < 32 for c in value), 'ID_MUST_BE_NONEMPTY_STRING')
+    return value
+
+def load(batch_path):
+    path = Path(batch_path).absolute()
+    require(path.is_file() and not path.is_symlink(), 'BATCH_NOT_FOUND')
+    require(path.stat().st_size <= 20000000, 'BATCH_MANIFEST_TOO_LARGE')
+    batch = json.loads(path.read_text(encoding='utf-8-sig'))
+    require(isinstance(batch, dict) and not set(batch) - {'schema', 'batchId', 'producer', 'sources', 'products', 'reviews', 'themes'}, 'INVALID_BATCH_FIELDS')
+    require(batch.get('schema') == SCHEMA, 'UNSUPPORTED_BATCH_SCHEMA')
+    identifier(batch.get('batchId'))
+    require(isinstance(batch.get('producer'), str) and 0 < len(batch['producer']) <= 200, 'PRODUCER_REQUIRED')
+    require(isinstance(batch.get('sources'), list) and 0 < len(batch['sources']) <= 1000, 'SOURCES_REQUIRED')
+    sources, assets = {}, {}
+    for source in batch['sources']:
+        require(isinstance(source, dict) and not set(source) - SOURCE_FIELDS, 'INVALID_SOURCE_FIELDS')
+        sid = identifier(source.get('id'))
+        require(sid not in sources, 'DUPLICATE_SOURCE_ID')
+        rel = source.get('path')
+        require(isinstance(rel, str) and '\\' not in rel and ':' not in rel and not PurePosixPath(rel).is_absolute()
+                and '..' not in PurePosixPath(rel).parts and rel.startswith('raw/'), 'INVALID_SOURCE_PATH')
+        p = path.parent / rel
+        require(not any(x.is_symlink() for x in (p, *p.parents)) and p.resolve().is_relative_to(path.parent.resolve()) and p.is_file(), 'SOURCE_MISSING_OR_REDIRECTED')
+        require(p.stat().st_size <= 50000000, 'SOURCE_TOO_LARGE')
+        raw = p.read_bytes()
+        require(hashlib.sha256(raw).hexdigest() == source.get('sha256'), 'SOURCE_HASH_MISMATCH')
+        require(source.get('platform') in ('jd', 'taobao') and source.get('marketplace') in
+                (('jd',) if source['platform'] == 'jd' else ('tmall', 'taobao_unverified')), 'SOURCE_PLATFORM_MISMATCH')
+        for field in ('observedAt', 'collectedAt', 'url'):
+            require(source.get(field) is None or isinstance(source[field], str), 'INVALID_SOURCE_METADATA')
+        if source.get('requestItemId') is not None: identifier(source['requestItemId'])
+        sources[sid] = (source, json.loads(raw.decode('utf-8-sig')))
+        require(rel not in assets or assets[rel][1] == source['sha256'], 'SOURCE_PATH_HASH_CONFLICT')
+        assets[rel] = (p, source['sha256'])
+    result = {'products': [], 'reviews': [], 'social': [], 'documents': [], 'clauses': []}
+    identifiers = collections.defaultdict(set)
+    for entity, mapping, mandatory in [('products', PRODUCT_FIELDS, {'itemId', 'title'}), ('reviews', REVIEW_FIELDS, {'itemId', 'reviewId', 'content'})]:
+        rows = batch.get(entity, [])
+        require(isinstance(rows, list) and len(rows) <= 100000, 'INVALID_BATCH_ROWS')
+        for record in rows:
+            require(isinstance(record, dict) and set(record) == {'sourceId', 'pointer', 'fields'}, 'INVALID_RECORD_MAPPING')
+            require(record['sourceId'] in sources and isinstance(record['fields'], dict) and mandatory <= set(record['fields'])
+                    and not set(record['fields']) - set(mapping), 'INVALID_FIELD_MAPPING')
+            source, payload = sources[record['sourceId']]
+            original = pointer(payload, record['pointer'])
+            values = {name: pointer(original, loc) for name, loc in record['fields'].items()}
+            iid = identifier(values['itemId'])
+            require(all(value is None or isinstance(value, (str, int, float, bool)) or name == 'appendContent' for name, value in values.items()), 'INVALID_MAPPED_FIELD_TYPE')
+            require('isDefault' not in values or values['isDefault'] is None or type(values['isDefault']) is bool, 'DEFAULT_STATE_MUST_BE_BOOLEAN_OR_NULL')
+            require('appendContent' not in values or values['appendContent'] is None or isinstance(values['appendContent'], (str, dict, list)), 'INVALID_APPEND_CONTENT')
+            for field in ('title', 'content', 'purchasedSku', 'ratingScale', 'reviewDate', 'sourceTime', 'priceBasis', 'url', 'shopName'):
+                require(field not in values or values[field] is None or isinstance(values[field], str), 'TEXT_FIELD_REQUIRED')
+            if entity == 'products':
+                require(isinstance(values['title'], str) and bool(values['title'].strip()), 'PRODUCT_TITLE_REQUIRED')
+                key = source['platform'] + ':' + iid
+                require(source.get('requestItemId') in (None, iid), 'PRODUCT_REQUEST_ID_MISMATCH')
+            else:
+                key = source['platform'] + ':' + iid + ':' + identifier(values['reviewId'])
+                require(isinstance(values['content'], str), 'REVIEW_CONTENT_REQUIRED')
+            require(key not in identifiers[entity], 'DUPLICATE_RECORD_ID')
+            identifiers[entity].add(key)
+            row = {mapping[k]: v for k, v in values.items()}
+            row.update(platform=source['platform'], marketplace=source['marketplace'], source_file=source['path'],
+                       source_time=values.get('sourceTime', source.get('observedAt')), collected_at=source.get('collectedAt'),
+                       source_locator=record['pointer'], source_field_map=record['fields'], producer=batch['producer'])
+            if entity == 'reviews':
+                row.update(observed_item_id=source.get('requestItemId'),
+                           item_identity_match=(iid == source['requestItemId']) if source.get('requestItemId') else None,
+                           review_id_method='source_field')
+            result[entity].append(row)
+    require(result['products'], 'PRODUCT_CONTEXT_REQUIRED')
+    require(all(r['platform'] + ':' + r['item_id'] in identifiers['products'] for r in result['reviews']), 'REVIEW_PRODUCT_CONTEXT_MISSING')
+    themes = batch.get('themes', [])
+    require(isinstance(themes, list) and len(themes) <= 100000, 'INVALID_THEMES')
+    from product_insights import append_text
+    by_review = {r['platform'] + ':' + r['item_id'] + ':' + r['review_id']: r for r in result['reviews']}
+    seen = set()
+    for tag in themes:
+        require(isinstance(tag, dict) and set(tag) == {'reviewId', 'theme', 'quote', 'quoteField'}, 'INVALID_THEME_FIELDS')
+        require(tag['reviewId'] in by_review and tag['theme'] in THEMES and tag['quoteField'] in ('content', 'append')
+                and isinstance(tag['quote'], str) and 0 < len(tag['quote']) <= 500, 'INVALID_THEME_EVIDENCE')
+        key = tag['reviewId'], tag['theme']
+        require(key not in seen, 'DUPLICATE_REVIEW_THEME')
+        seen.add(key)
+        review = by_review[tag['reviewId']]
+        original = review['content'] if tag['quoteField'] == 'content' else append_text(voc_db.canonical(review.get('append_content')))
+        require(tag['quote'] in original, 'THEME_QUOTE_NOT_VERBATIM')
+    return path, batch, result, assets
+
+def build_batch(batch_path, output, apply=False):
+    path, batch, rows, assets = load(batch_path)
+    target = Path(output).absolute()
+    require(target.suffix == '.sqlite' and target != path and not target.exists() and not target.is_symlink(), 'NEW_DATABASE_TARGET_REQUIRED')
+    require(not any(p.is_symlink() for p in target.parents), 'REDIRECTED_TARGET')
+    archive = target.with_suffix('.source.zip')
+    require(not archive.exists() and not archive.is_symlink(), 'SOURCE_ARCHIVE_ALREADY_EXISTS')
+    receipt = {'ok': True, 'readOnly': not apply, 'applied': False, 'schema': SCHEMA, 'batchId': batch['batchId'],
+               'counts': {k: len(v) for k, v in rows.items()}, 'sourceFilesVerified': len(assets),
+               'themeAnnotations': len(batch.get('themes', [])), 'networkRequests': 0, 'productionImported': False,
+               'databaseWrites': 0, 'activeLibraryModified': False}
+    if not apply: return receipt
+    target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+    created = []
+    with tempfile.TemporaryDirectory(prefix='.agent-batch-', dir=target.parent) as temp:
+        temp = Path(temp)
+        packed, staged = temp / 'source.zip', temp / 'batch.sqlite'
+        with zipfile.ZipFile(packed, 'x', zipfile.ZIP_DEFLATED) as z:
+            def write(name, body):
+                info = zipfile.ZipInfo('agent-batch/' + name, (2026, 1, 1, 0, 0, 0))
+                info.external_attr = 0o100600 << 16
+                z.writestr(info, body, compress_type=zipfile.ZIP_DEFLATED)
+            for entity, filename in voc_db.PRIMARY.items():
+                write('01_最新底库/' + filename, '\n'.join(voc_db.canonical(r) for r in rows[entity]))
+            manifest = path.read_bytes()
+            require(json.loads(manifest.decode('utf-8-sig')) == batch, 'BATCH_CHANGED_DURING_BUILD')
+            write('batch.json', manifest)
+            for rel, (p, expected) in sorted(assets.items()):
+                raw = p.read_bytes()
+                require(hashlib.sha256(raw).hexdigest() == expected, 'SOURCE_CHANGED_DURING_BUILD')
+                write('02_采集档案/' + rel, raw)
+        built = voc_db.build(packed, staged)
+        if batch.get('themes'):
+            c = sqlite3.connect(staged); c.row_factory = sqlite3.Row
+            try:
+                c.executescript(ANNOTATION_DDL)
+                for tag in batch['themes']:
+                    r = c.execute('SELECT * FROM reviews WHERE id=?', (tag['reviewId'],)).fetchone()
+                    voc_db.insert(c, 'review_theme_annotations', {'id': voc_db.key(tag, batch['producer']), 'review_id': r['id'],
+                        'product_id': r['product_id'], 'theme_code': tag['theme'], 'quote': tag['quote'], 'quote_field': tag['quoteField'],
+                        'text_sha256': text_hash(r), 'method': 'agent', 'producer': batch['producer']})
+                generation = voc_db.key(built['generation'], digest(Path(__file__)), digest(Path(__file__).with_name('product_insights.py')), batch['themes'])
+                c.execute("UPDATE meta SET value=? WHERE key='generation'", (generation,)); c.commit()
+            finally: c.close()
+        from update_library import verified
+        meta = verified(staged)
+        try:
+            for src, dest in ((packed, archive), (staged, target)):
+                os.chmod(src, 0o600); os.link(src, dest); created.append((dest, digest(src)))
+        except Exception:
+            for p, h in reversed(created):
+                if p.is_file() and digest(p) == h: p.unlink()
+            raise
+    return {**receipt, 'readOnly': False, 'applied': True, 'databaseWrites': 'staging-only', 'generation': meta['generation'],
+            'database': str(target), 'databaseSha256': digest(target), 'sourceArchive': str(archive), 'archiveSha256': digest(archive)}
+
+def main():
+    p = argparse.ArgumentParser(description=__doc__)
+    p.add_argument('--batch', required=True); p.add_argument('--output', required=True)
+    p.add_argument('--apply', action='store_true')
+    args = p.parse_args()
+    try: print(json.dumps(build_batch(args.batch, args.output, args.apply), ensure_ascii=False, indent=2))
+    except Exception as e:
+        code = str(e) if isinstance(e, voc_db.LibraryError) else 'AGENT_BATCH_FAILED'
+        print(json.dumps({'ok': False, 'error': {'code': code}, 'productionImported': False}, ensure_ascii=False), file=sys.stderr)
+        return 1
+    return 0
+
+if __name__ == '__main__': raise SystemExit(main())

+ 2 - 0
server/voc-library/board_library.py

@@ -5,6 +5,7 @@ import json
 from pathlib import Path
 import voc_db
 import retrieval
+from product_insights import overview as product_overview
 
 LABELS = {'products':'商品与竞品','reviews':'电商评价','social':'社媒内容','documents':'政策文件',
           'clauses':'条款与应用','prices':'价格快照','specs':'商品规格','rankings':'榜单线索','coverage':'采集覆盖'}
@@ -142,6 +143,7 @@ def detail(c,p):
     record['issues']=[r['code'] for r in issues]
     relations=[]
     if entity=='products':
+        record['overview'] = product_overview(c, record)
         for related in ('reviews','prices','specs','rankings','coverage'):
             q={'entities':[related],'filters':{'productId':rid},'quality':'audit'}
             where,args,_=retrieval.prepare(c,q)

+ 117 - 0
server/voc-library/product_insights.py

@@ -0,0 +1,117 @@
+"""Product-scoped, reproducible review statistics with traceable theme examples.
+
+Existing v2 snapshots need no migration. Optional Agent annotations are accepted
+only while their product/review/text fingerprint still matches the active data.
+"""
+import collections
+import hashlib
+import json
+import re
+import statistics
+import unicodedata
+
+VERSION = 'ksf.product-overview.v1'
+THEMES = {
+    'flavour': ('口味与调味', ('味道', '口味', '好吃', '难吃', '香味', '好香', '美味', '太咸', '偏咸', '太淡', '辣', '鲜香')),
+    'texture': ('面条口感', ('口感', '劲道', '筋道', 'q弹', '软硬', '嚼劲', '面条', '面饼')),
+    'portion': ('份量与饱腹', ('份量', '分量', '面量', '量少', '量小', '不够吃', '吃不饱', '吃得饱', '量有点少', '量太少')),
+    'value': ('价格与性价比', ('价格', '性价比', '便宜', '实惠', '划算', '太贵', '优惠', '物有所值')),
+    'preparation': ('食用便利', ('方便', '省事', '速食', '煮', '泡', '加热', '宵夜', '夜宵')),
+    'packaging': ('包装与完整性', ('包装', '破损', '漏', '碎', '压坏', '密封', '变形')),
+    'delivery': ('配送与服务', ('物流', '快递', '配送', '送货', '发货', '客服', '服务')),
+    'repurchase': ('复购与推荐', ('回购', '复购', '再次购买', '再买', '一直买', '多次购买', '推荐', '囤货')),
+}
+ANNOTATION_DDL = '''CREATE TABLE IF NOT EXISTS review_theme_annotations(
+ id TEXT PRIMARY KEY, review_id TEXT NOT NULL REFERENCES reviews(id), product_id TEXT NOT NULL REFERENCES products(id),
+ theme_code TEXT NOT NULL, quote TEXT NOT NULL, quote_field TEXT NOT NULL,
+ text_sha256 TEXT NOT NULL, method TEXT NOT NULL, producer TEXT NOT NULL,
+ UNIQUE(review_id,theme_code,method,producer,text_sha256));
+ CREATE INDEX IF NOT EXISTS idx_review_themes_product ON review_theme_annotations(product_id,review_id);'''
+
+def normalize(text):
+    return ' '.join(unicodedata.normalize('NFKC', str(text or '')).casefold().split())
+
+def text_hash(row):
+    # Bind annotations to the original separate text fields, including appended text.
+    return hashlib.sha256(json.dumps([row['content'], row['append_content_json']], ensure_ascii=False, separators=(',', ':')).encode()).hexdigest()
+
+def append_text(raw):
+    value = json.loads(raw or 'null')
+    if isinstance(value, str): return value
+    if isinstance(value, dict): return '\n'.join(value[k] for k in ('content', 'comment', 'appendComment') if isinstance(value.get(k), str))
+    if isinstance(value, list): return '\n'.join(append_text(json.dumps(v)) for v in value if isinstance(v, (str, dict)))
+    return ''
+
+def excerpt(text, terms):
+    start = next((normalize(text).find(t) for t in terms if t in normalize(text)), 0)
+    # Never mark a normalized match as a verbatim quote: select actual source text.
+    start = max(0, min(start - 24, len(text) - 130))
+    return text[start:start + 170].strip()
+
+def overview(c, product):
+    pid = product['id']
+    all_rows = [dict(r) for r in c.execute('SELECT * FROM reviews WHERE product_id=? ORDER BY id', (pid,))]
+    eligible = [r for r in all_rows if r['is_default'] == 0 and r['identity_state'] == 'matched' and r['category_state'] != 'conflict']
+    # Product overview describes the linked product; strict category analyses remain separate queries.
+    groups = collections.defaultdict(list)
+    for row in eligible:
+        key = normalize(row['content'] + '\n' + append_text(row['append_content_json']))
+        if key: groups[key].append(row)
+    annotations = collections.defaultdict(list)
+    if c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='review_theme_annotations'").fetchone():
+        for row in c.execute('SELECT * FROM review_theme_annotations WHERE product_id=? ORDER BY id', (pid,)):
+            annotations[row['review_id']].append(dict(row))
+    buckets = {code: {'id': code, 'label': label, 'recordCount': 0, 'distinctTextCount': 0, 'examples': [], 'methods': set()}
+               for code, (label, _) in THEMES.items()}
+    tagged = 0
+    for rows in groups.values():
+        matches = {}
+        for row in rows:
+            parts = {'content': row['content'], 'append': append_text(row['append_content_json'])}
+            for code, (_, terms) in THEMES.items():
+                field = next((field for field, text in parts.items() if any(term in normalize(text) for term in terms)), None)
+                if field:
+                    matches.setdefault(code, {})[row['id']] = (row, field, excerpt(parts[field], terms), 'keyword')
+            for a in annotations[row['id']]:
+                if (a['theme_code'] in THEMES and a['method'] == 'agent' and a['text_sha256'] == text_hash(row)
+                        and a['quote_field'] in parts and a['quote'] and a['quote'] in parts[a['quote_field']]):
+                    matches.setdefault(a['theme_code'], {})[row['id']] = (row, a['quote_field'], a['quote'], 'agent')
+        if matches: tagged += 1
+        for code, evidence in matches.items():
+            b = buckets[code]
+            b['distinctTextCount'] += 1
+            b['recordCount'] += len(evidence)
+            b['methods'].update(v[3] for v in evidence.values())
+            row, field, quote, method = sorted(evidence.values(), key=lambda v: (v[3] != 'agent', v[0]['id']))[0]
+            if len(b['examples']) < 3:
+                b['examples'].append({'reviewId': row['id'], 'productId': pid, 'sourceId': row['source_record_id'],
+                    'quote': quote, 'quoteField': field, 'method': method, 'purchasedSku': row['purchased_sku_text'], 'reviewDate': row['review_date_raw']})
+    themes = []
+    for b in sorted(buckets.values(), key=lambda b: (-b['distinctTextCount'], -b['recordCount'], b['id'])):
+        if b['distinctTextCount']:
+            b.update(share=round(b['distinctTextCount'] / len(groups) * 100, 1), methods=sorted(b['methods']))
+            themes.append(b)
+    ratings = collections.defaultdict(list)
+    for r in eligible:
+        if r['rating_scale'] and r['rating_raw'] is not None and str(r['rating_raw']).strip():
+            ratings[r['rating_scale']].append(str(r['rating_raw']))
+    distributions = []
+    for scale, values in sorted(ratings.items()):
+        numeric = [float(v) for v in values if re.fullmatch(r'\d+(?:\.\d+)?', v) and float(v) <= 100000]
+        distributions.append({'scale': scale, 'count': len(values),
+            'average': round(statistics.mean(numeric), 2) if len(numeric) == len(values) else None,
+            'distribution': [{'value': v, 'count': n} for v, n in sorted(collections.Counter(values).items())]})
+    price_groups = []
+    for group in c.execute('SELECT currency,price_basis,count(*) count,min(amount_minor) min,max(amount_minor) max FROM prices WHERE product_id=? AND amount_minor IS NOT NULL GROUP BY currency,price_basis ORDER BY count DESC,price_basis', (pid,)):
+        price_groups.append(dict(group))
+    dates = [r['review_calendar_date'] for r in eligible if r['review_calendar_date']]
+    return {'schema': VERSION, 'productId': pid,
+        'reviews': {'collected': len(all_rows), 'eligible': len(eligible), 'distinctTexts': len(groups),
+            'categoryConfirmed': sum(r['category_state'] == 'consistent' for r in eligible),
+            'excluded': len(all_rows) - len(eligible), 'unknownDates': sum(not r['review_calendar_date'] for r in eligible),
+            'firstDate': min(dates) if dates else None, 'lastDate': max(dates) if dates else None,
+            'rated': sum(r['count'] for r in distributions), 'ratingGroups': distributions},
+        'themes': themes, 'taggedDistinctTexts': tagged, 'untaggedDistinctTexts': len(groups) - tagged,
+        'themeBasis': 'distinct_review_texts', 'themeMethod': 'keyword_with_evidenced_agent_annotations',
+        'prices': price_groups, 'sampleScope': 'product_link_non_default_matched_no_sku_conflict',
+        'note': '仅统计该商品已收录评价;主题可重叠,按去重文本计数,关键词提及不代表好评或差评。'}

+ 82 - 0
server/voc-library/test_agent_batch.py

@@ -0,0 +1,82 @@
+import hashlib
+import json
+from pathlib import Path
+import sqlite3
+import tempfile
+import unittest
+import agent_batch
+import board_library
+import update_library
+import voc_db
+
+class AgentBatchTests(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup)
+        self.root = Path(self.tmp.name); (self.root / 'raw').mkdir()
+        self.raw = self.root / 'raw/response.json'
+        self.raw.write_text(json.dumps({'product': {'id': '123', 'name': '康师傅香辣拌面110g*5袋', 'price': '19.9'},
+            'comments': [{'item': '123', 'id': 'r1', 'text': '一包根本吃不饱', 'default': False, 'sku': '香辣拌面'}]}, ensure_ascii=False), encoding='utf-8')
+        self.batch = {'schema': agent_batch.SCHEMA, 'batchId': 'batch-test', 'producer': 'test-agent/v1',
+            'sources': [{'id': 'run1', 'path': 'raw/response.json', 'sha256': agent_batch.digest(self.raw),
+                         'platform': 'jd', 'marketplace': 'jd', 'requestItemId': '123', 'observedAt': '2026-11-07T10:00:00+08:00'}],
+            'products': [{'sourceId': 'run1', 'pointer': '/product', 'fields': {'itemId': '/id', 'title': '/name', 'price': '/price'}}],
+            'reviews': [{'sourceId': 'run1', 'pointer': '/comments/0', 'fields': {'itemId': '/item', 'reviewId': '/id',
+                        'content': '/text', 'isDefault': '/default', 'purchasedSku': '/sku'}}],
+            'themes': [{'reviewId': 'jd:123:r1', 'theme': 'portion', 'quote': '一包根本吃不饱', 'quoteField': 'content'}]}
+        self.path = self.root / 'batch.json'; self.output = self.root / 'new.sqlite'; self.save()
+    def save(self): self.path.write_text(json.dumps(self.batch, ensure_ascii=False), encoding='utf-8')
+    def test_mapping_validation_is_readonly_then_build_publish_and_query(self):
+        before = {str(p): (agent_batch.digest(p), p.stat().st_mtime_ns) for p in self.root.rglob('*') if p.is_file()}
+        dry = agent_batch.build_batch(self.path, self.output)
+        self.assertTrue(dry['readOnly']); self.assertFalse(self.output.exists())
+        self.assertEqual(before, {str(p): (agent_batch.digest(p), p.stat().st_mtime_ns) for p in self.root.rglob('*') if p.is_file()})
+        built = agent_batch.build_batch(self.path, self.output, True)
+        self.assertTrue(built['applied']); self.assertFalse(built['activeLibraryModified']); self.assertTrue(self.output.with_suffix('.source.zip').is_file())
+        registry = self.root / 'registry'; update_library.publish(self.output, registry, True)
+        d = board_library.run({'registry': str(registry)}, {'op': 'detail', 'entity': 'products', 'id': 'jd:123'})
+        self.assertEqual(d['record']['overview']['reviews']['eligible'], 1)
+        theme = next(t for t in d['record']['overview']['themes'] if t['id'] == 'portion')
+        self.assertIn('agent', theme['methods']); self.assertEqual(theme['examples'][0]['quote'], '一包根本吃不饱')
+        with voc_db.readonly(self.output) as c:
+            r = c.execute('SELECT source_record_id FROM reviews').fetchone()
+            citation = voc_db.citation(c, r[0]); self.assertTrue(citation['archiveSha256'])
+        with self.assertRaises(voc_db.LibraryError): agent_batch.build_batch(self.path, self.output, True)
+    def test_wrong_product_default_type_fabricated_quote_and_missing_map_rejected(self):
+        cases = [('source-mismatch', lambda: self.batch['sources'][0].update(requestItemId='999')),
+                 ('fabricated-quote', lambda: self.batch['themes'][0].update(quote='味道鲜美')),
+                 ('missing-content', lambda: self.batch['reviews'][0]['fields'].pop('content')),
+                 ('duplicate', lambda: self.batch['reviews'].append(self.batch['reviews'][0]))]
+        original = json.loads(json.dumps(self.batch))
+        for name, mutate in cases:
+            with self.subTest(name=name):
+                self.batch = json.loads(json.dumps(original)); mutate(); self.save()
+                with self.assertRaises(voc_db.LibraryError): agent_batch.build_batch(self.path, self.output)
+                self.assertFalse(self.output.exists())
+    def test_wrong_hash_and_source_path_escape_rejected(self):
+        self.batch['sources'][0]['sha256'] = '0' * 64; self.save()
+        with self.assertRaisesRegex(voc_db.LibraryError, 'SOURCE_HASH_MISMATCH'): agent_batch.build_batch(self.path, self.output)
+        self.batch['sources'][0]['path'] = '../outside.json'; self.save()
+        with self.assertRaisesRegex(voc_db.LibraryError, 'INVALID_SOURCE_PATH'): agent_batch.build_batch(self.path, self.output)
+    def test_null_title_and_non_boolean_default_rejected(self):
+        raw = json.loads(self.raw.read_text(encoding='utf-8'))
+        raw['product']['name'] = None
+        self.raw.write_text(json.dumps(raw), encoding='utf-8')
+        self.batch['sources'][0]['sha256'] = agent_batch.digest(self.raw); self.save()
+        with self.assertRaisesRegex(voc_db.LibraryError, 'PRODUCT_TITLE_REQUIRED'): agent_batch.build_batch(self.path, self.output)
+        raw['product']['name'] = '康师傅拌面'
+        raw['comments'][0]['default'] = 'false'
+        self.raw.write_text(json.dumps(raw), encoding='utf-8')
+        self.batch['sources'][0]['sha256'] = agent_batch.digest(self.raw); self.save()
+        with self.assertRaisesRegex(voc_db.LibraryError, 'DEFAULT_STATE_MUST_BE_BOOLEAN_OR_NULL'): agent_batch.build_batch(self.path, self.output)
+    def test_new_batch_merged_annotations_survive_old_snapshot_and_updates(self):
+        agent_batch.build_batch(self.path, self.output, True)
+        registry = self.root / 'registry'; update_library.publish(self.output, registry, True)
+        self.batch['batchId'] = 'batch-two'; self.batch['producer'] = 'test-agent/v2'; self.save()
+        second = self.root / 'second.sqlite'; agent_batch.build_batch(self.path, second, True)
+        change = update_library.publish(second, registry, True)
+        self.assertTrue(change['applied'])
+        d = board_library.run({'registry': str(registry)}, {'op': 'detail', 'entity': 'products', 'id': 'jd:123'})
+        self.assertEqual(d['record']['overview']['reviews']['collected'], 1)
+        self.assertIn('agent', next(t for t in d['record']['overview']['themes'] if t['id'] == 'portion')['methods'])
+
+if __name__ == '__main__': unittest.main()

+ 65 - 0
server/voc-library/test_product_insights.py

@@ -0,0 +1,65 @@
+import json
+from pathlib import Path
+import shutil
+import sqlite3
+import tempfile
+import unittest
+import test_voc_db as fixture
+import board_library
+import product_insights as insights
+import voc_db
+
+class ProductInsightsTests(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls): fixture.VocLibraryTests.setUpClass()
+    @classmethod
+    def tearDownClass(cls): fixture.VocLibraryTests.tearDownClass()
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup)
+        self.db = Path(self.tmp.name) / 'library.sqlite'; shutil.copyfile(fixture.VocLibraryTests.db, self.db)
+    def overview(self, pid='jd:same'):
+        return board_library.run({'db': str(self.db)}, {'op': 'detail', 'entity': 'products', 'id': pid})['record']['overview']
+    def add_review(self, rid, content, **overrides):
+        c = sqlite3.connect(self.db); c.row_factory = sqlite3.Row
+        row = dict(c.execute('SELECT * FROM reviews WHERE id=?', ('jd:same:106582280238337296',)).fetchone())
+        row.update(id='jd:same:' + rid, review_id=rid, content=content, **overrides)
+        voc_db.insert(c, 'reviews', row); c.commit(); c.close()
+    def test_full_scope_counts_deduplicate_and_exclude_defaults_conflicts(self):
+        self.add_review('duplicate', '面量有点少')
+        self.add_review('second', '面量有点少,价格太贵', rating_raw='1')
+        self.add_review('append', '口感不错', append_content_json=json.dumps({'content': '包装完整,物流很快'}))
+        before = voc_db.sha(self.db.read_bytes())
+        r = self.overview()
+        self.assertEqual(r['reviews']['collected'], 8)
+        self.assertEqual(r['reviews']['eligible'], 5)  # 2 fixture-eligible, plus three new rows
+        self.assertEqual(r['reviews']['distinctTexts'], 3)
+        portion = next(t for t in r['themes'] if t['id'] == 'portion')
+        self.assertEqual(portion['recordCount'], 4)
+        self.assertEqual(portion['distinctTextCount'], 2)
+        self.assertEqual(portion['share'], 66.7)
+        self.assertTrue(all(e['productId'] == 'jd:same' and e['sourceId'] for t in r['themes'] for e in t['examples']))
+        self.assertEqual(voc_db.sha(self.db.read_bytes()), before)
+        other = self.overview('taobao:same')
+        self.assertEqual(other['reviews']['collected'], 0); self.assertEqual(other['themes'], [])
+    def test_rating_scales_and_price_bases_stay_separate_unknowns_not_zero(self):
+        self.add_review('different-scale', '不好吃但价格便宜', rating_raw='100', rating_scale='百分制', review_calendar_date=None)
+        r = self.overview()
+        self.assertEqual(len(r['reviews']['ratingGroups']), 2)
+        self.assertEqual(next(g for g in r['reviews']['ratingGroups'] if g['scale'] == '百分制')['average'], 100)
+        self.assertEqual(r['reviews']['unknownDates'], 1)
+        self.assertNotIn('positiveRate', r)
+        self.assertFalse(any('好评' in t['label'] or '差评' in t['label'] for t in r['themes']))
+        self.assertEqual(self.overview('jd:unknown')['reviews']['ratingGroups'], [])
+    def test_agent_annotations_require_matching_text_and_verbatim_quote(self):
+        c = sqlite3.connect(self.db); c.row_factory = sqlite3.Row; c.executescript(insights.ANNOTATION_DDL)
+        row = dict(c.execute("SELECT * FROM reviews WHERE id='jd:same:106582280238337296'").fetchone())
+        tag = dict(id='tag1', review_id=row['id'], product_id='jd:same', theme_code='portion', quote='面量有点少',
+                   quote_field='content', text_sha256=insights.text_hash(row), method='agent', producer='test-agent')
+        voc_db.insert(c, 'review_theme_annotations', tag); c.commit()
+        self.assertIn('agent', next(t for t in self.overview()['themes'] if t['id'] == 'portion')['methods'])
+        c.execute("UPDATE review_theme_annotations SET quote='捏造的引文'"); c.commit()
+        self.assertNotIn('agent', next(t for t in self.overview()['themes'] if t['id'] == 'portion')['methods'])
+        c.execute("UPDATE review_theme_annotations SET quote='面量有点少',text_sha256='stale'"); c.commit(); c.close()
+        self.assertNotIn('agent', next(t for t in self.overview()['themes'] if t['id'] == 'portion')['methods'])
+
+if __name__ == '__main__': unittest.main()

+ 5 - 1
server/voc-library/update_library.py

@@ -16,10 +16,11 @@ import tempfile
 import uuid
 import voc_db
 import retrieval
+from product_insights import ANNOTATION_DDL
 
 ORDER=['batches','assets','collection_requests','brands','categories','source_records','source_links','products',
        'classification_assertions','product_observations','specs','prices','price_sources','reviews','social_posts',
-       'social_brand_mentions','policy_documents','policy_clauses','ranking_signals','coverage_observations','quality_issues']
+       'social_brand_mentions','policy_documents','policy_clauses','ranking_signals','coverage_observations','quality_issues','review_theme_annotations']
 STABLE={'products','reviews','social_posts','policy_documents','policy_clauses'}
 def digest(p):
     with Path(p).open('rb') as f:return hashlib.file_digest(f,'sha256').hexdigest()
@@ -29,6 +30,7 @@ def verified(p):
         voc_db.require(c.execute('PRAGMA integrity_check').fetchone()[0]=='ok' and not c.execute('PRAGMA foreign_key_check').fetchall(),'DATABASE_CHECK_FAILED')
         voc_db.require(c.execute('SELECT count(*) FROM retrieval_documents').fetchone()[0]==sum(c.execute('SELECT count(*) FROM '+t).fetchone()[0] for t in retrieval.ENTITIES.values()),'RETRIEVAL_COUNT_MISMATCH')
         for table in ORDER:
+            if table=='review_theme_annotations':continue  # Optional, backward-compatible enrichment.
             voc_db.require(c.execute('SELECT sql FROM sqlite_master WHERE type=? AND name=?',('table',table)).fetchone() is not None,'MISSING_TABLE:'+table)
         return dict(c.execute('SELECT * FROM meta'))
 
@@ -41,6 +43,7 @@ def merge(current,incoming,dest):
     c=sqlite3.connect(dest);c.row_factory=sqlite3.Row;c.execute('PRAGMA foreign_keys=ON')
     maps={};delta={'added':{},'changed':{},'duplicate':{}}
     try:
+        c.executescript(ANNOTATION_DDL)
         c.execute('CREATE TABLE IF NOT EXISTS source_origins(source_record_id TEXT NOT NULL REFERENCES source_records(id),archive_sha256 TEXT NOT NULL,PRIMARY KEY(source_record_id,archive_sha256))')
         c.execute('CREATE TABLE IF NOT EXISTS record_versions(entity TEXT NOT NULL,record_id TEXT NOT NULL,batch_sha256 TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(entity,record_id,batch_sha256))')
         baseline=dict(c.execute('SELECT * FROM meta'))
@@ -48,6 +51,7 @@ def merge(current,incoming,dest):
         if current:
             with voc_db.readonly(incoming) as inc:
                 for table in ORDER:
+                    if not inc.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",(table,)).fetchone():continue
                     info=c.execute('PRAGMA table_info('+table+')').fetchall()
                     pk=[r['name'] for r in sorted(info,key=lambda r:r['pk']) if r['pk']]
                     fks=[dict(r) for r in c.execute('PRAGMA foreign_key_list('+table+')')]

+ 13 - 9
skills/ksf-voc-library/FILES.sha256

@@ -1,9 +1,13 @@
-6a1be59c7ae18db865f87c9b6e0525bbc2ab93addd499e2761dbcf4020415247  references/data-contract.md
-b94f92521a8e1f796788b34fa96c550362c41c6245a1940b936395a4ee4f7a3a  references/query-rules.md
-d2b14e1e41c16d97d8d7130c5252319a2ed8876b568866047aabdf7b3f7f2fb1  references/retrieval-rules.md
-c4b688d8ea0368f07bdf93b4a3b66800cc0d89a81da4dc453798c74b556638c2  scripts/query_voc.py
-5d684df69bcedd2d6769aee4328011903ec6e5f52649e96508f53deeb0b0a27c  scripts/retrieval.py
-a69c918046b961c91c33a0edb388157778ddf04e9b8c97541f107bb4ffc366cd  scripts/schema.sql
-861e86adc4070f4d9cb92f507b598960790de33ed27663cd4c966d987d0ce478  scripts/taxonomy.json
-a92ea4ddfe351324fded77f2d740e2d842648a4eba60c09889889447551894b7  scripts/voc_db.py
-5f5a3ef50525437f60db142e8d5e424a441354c215cb5bac4deb425ccda422ad  SKILL.md
+6a1be59c7ae18db865f87c9b6e0525bbc2ab93addd499e2761dbcf4020415247  references/data-contract.md
+96cdac5011d52c30a68e52712e43d73b8d1ab5457c5051f7d1c33eaea01051ea  references/product-overview-and-intake.md
+b94f92521a8e1f796788b34fa96c550362c41c6245a1940b936395a4ee4f7a3a  references/query-rules.md
+d2b14e1e41c16d97d8d7130c5252319a2ed8876b568866047aabdf7b3f7f2fb1  references/retrieval-rules.md
+9a72eeac48d2e565f5db481f203cdbc503600a10920019d084a6a615b84e859a  scripts/agent_batch.py
+fb218888498128aaf621a983a1798211883a475d80a29ab5f63023b7fda9dd52  scripts/product_insights.py
+c4b688d8ea0368f07bdf93b4a3b66800cc0d89a81da4dc453798c74b556638c2  scripts/query_voc.py
+5d684df69bcedd2d6769aee4328011903ec6e5f52649e96508f53deeb0b0a27c  scripts/retrieval.py
+a69c918046b961c91c33a0edb388157778ddf04e9b8c97541f107bb4ffc366cd  scripts/schema.sql
+861e86adc4070f4d9cb92f507b598960790de33ed27663cd4c966d987d0ce478  scripts/taxonomy.json
+0187c7c8bd9378167624be5f0f371dca391586793aeb40fc60c31491272a6a2e  scripts/update_library.py
+a92ea4ddfe351324fded77f2d740e2d842648a4eba60c09889889447551894b7  scripts/voc_db.py
+5208b585a7e6404d3ccc4f88f591f8aac13829875a0cc3d3b1d2ddc993ffe192  SKILL.md

+ 8 - 2
skills/ksf-voc-library/SKILL.md

@@ -1,11 +1,11 @@
 ---
 name: ksf-voc-library
-description: 查询康师傅面类创新研究资料。将商品识别、竞品比较、消费反馈、规格价格、政策条款、采集覆盖等问题转成可核验的数据库检索,通过字段筛选、正文搜索、同义表达、精确关联与来源回读找数据,先查库再补采;报告和项目另走工作坊业务库。
+description: 查询康师傅面类创新研究资料,整理可追溯的商品评价主题与新增资料批次。将商品识别、竞品比较、消费反馈、规格价格、政策条款、采集覆盖等问题转成可核验的检索,先查库再补采;新商品与评价按原始响应映射、校验后构建暂存库,发布走维护流程;报告和项目另走工作坊业务库。
 ---
 
 # VOC 结构化研究库
 
-这是已采集研究资料的专用只读库。工作坊报告、员工身份、机会卡与项目仍走 `ksf-workshop-runtime` 的业务库命令;两个库都要按任务查,不能用一个的零结果推断另一个为空。
+这是已采集研究资料的专用库,查询入口只读。新增资料只可经下文批次校验器构建独立暂存库,不直接改当前库。工作坊报告、员工身份、机会卡与项目仍走 `ksf-workshop-runtime` 的业务库命令;两个库都要按任务查,不能用一个的零结果推断另一个为空。
 
 本版规则 `ksf.agent-data-rules.20261106.v1`。先读 [问题拆解与检索规则](references/retrieval-rules.md);首次使用某类数据、分类或证据时读 [数据结构与来源合同](references/data-contract.md)。平台、品牌、品类是可用条件,不是所有任务都要补齐的固定表单。入口不读取通讯录、上游或运维凭据,不接受写命令。
 
@@ -30,3 +30,9 @@ python3 /opt/data/skills/ksf-voc-library/scripts/query_voc.py source --id 实际
 6. 回答附实际条件、代际、命中记录/关联商品/去重文本数、读取范围、排除规则、来源与未知时间。只看一页不能称全量分析;需要全范围统计优先 aggregate,不逐条下载所有大载荷。够用复用;不足先同范围补查,确实缺失再用实际存在且已授权的工具补采。本Skill不采集、不收费。
 
 管理员构建/更新数据库采用包内受控导入程序,每版新建并校验后切换;Agent 不直接改 SQLite、分类字典、旧记录或源文件。更正必须带原记录、来源依据及规则版本,不能按同名品牌或近似标题合并。
+
+## 商品评价概况与新增资料
+
+整理商品高频反馈、生成看板概况,或从已授权采集结果准备新一批商品/评价入库时,读 [商品概况与结构化批次](references/product-overview-and-intake.md)。商品概况按本商品已收录评价统计:默认/错配/规格冲突不参与分析,主题以去重文本计数,评分不跨体系平均,价格不混口径。主题结论须附原评价ID和逐字引文,不能把关键词命中直接说成好评、差评或消费者占比。
+
+Agent可准备批次映射和有引文的主题标注,通过 `scripts/agent_batch.py --batch /opt/data/本批/batch.json --output /opt/data/本批/staging.sqlite` 做只读预检;来源固定到真实文件SHA、JSON Pointer、平台和商品ID。不直接写活动库。批次构建的--apply只创建新的私有暂存库及来源包,正式合并/切换走维护人已授权的 `update_library.py`,采集工具和付费权限仍须实际存在。

+ 42 - 0
skills/ksf-voc-library/references/product-overview-and-intake.md

@@ -0,0 +1,42 @@
+# 商品概况与Agent结构化入库
+
+## 看板如何统计
+
+`ksf.product-overview.v1`挂在商品detail的record.overview。精确product_id对应一个平台接口族+item_id,不能跨商品/平台合并。
+
+- collected为全部收录评价;eligible排除is_default非明确false、identity非matched、购买规格冲突;categoryConfirmed另列规格与品类明确一致的记录。商品链接概况可含listing_only,严格指定品类的研究查询仍要求consistent,不把两个分母混用。
+- distinctTexts按原评+追加正文做NFKC/空白归一后去重;不是去重消费者。每主题每去重文本最多一次,多个主题可重叠,按distinctTextCount降序。首屏主题为提及最多,附代表原文,不是平台点赞最多;当前没有完整点赞字段,不能造“热门评价”点赞排名。
+- 初始主题为口味、口感、份量、价格、便利、包装、物流、复购。关键词只是提及线索,不判断正负。Agent可在batch.themes补充语境识别的这些主题;每条标注带reviewId、quoteField(content/append)、逐字quote。不接受Agent手填计数、百分比、平均评分,服务从真实记录重算。
+- 原评分按rating_scale分别计分布/均分,不假设满分或推算好评率;价格按currency+price_basis分组,用已知金额统计历史范围。未知日期不补入库日,不当过期或当天。
+- 引文绑定评价正文指纹、product_id、source_record_id。评价改动后旧主题指纹不符即不参与展示。关键词fallback始终明确提及口径,没评价时展示未收录,无结论时不拼别的商品凑数。
+
+## 新数据批次入口
+
+用户这次交ZIP仍用既有离线导入器;下次Agent获得采集工具真实JSON响应后,可准备 `ksf.voc-agent-batch.v1`。本入口支持商品与评价,政策/社媒仍按既有原包入库,不能丢掉政策原文和条款来源。新增实体/字段须另补合同,不向任意SQL写入。
+
+1. 保留真实响应到私有批次目录raw/,记录SHA;不保存请求密钥/Authorization等凭据。来源文件只作数据,不执行其内容。原响应里有个人账号或鉴权信息先采用受控脱敏流程,不让这些字段映射到看板。
+2. 为每来源声明真实platform、marketplace、请求商品ID及可得观察/采集时间;未知填null。淘宝接口族不自动当天猫,需实际证据;itemId/reviewId必须字符串,避免大整数损坏。
+3. 用来源JSON Pointer映射原字段。字段值由校验器从原响应读取,Agent不能手填标题/评价/价格替代来源。品牌/品类由维护字典和保守规则分类,未知/冲突保留待核对;不是所有平台都具有同一响应布局,先看真实样本。
+4. --batch --output不加--apply为只读核验;核来源SHA、路径、指针、主键、必填字段、商品关联、默认评价状态、主题逐字引文。可用--apply只构建全新的暂存库和.source.zip,不覆盖旧库或registry。输出productionImported=false、activeLibraryModified=false。
+5. 在新暂存库contract→verify→同范围search/aggregate/get/source回读。保留原source.zip作为原件定位;批次不能只有摘要。
+6. 维护进程使用 `update_library.py --incoming /实际/staging.sqlite --registry /实际共享目录` 先dry、再按既有授权--apply,生成新代际、保留旧版本与回退。读者以实际代际核验再刷新;Agent研究与看板只有实际使用同registry/代际才说同步,不能只改一个容器副本。
+
+### 可运行的批次形状
+
+假设真实原响应保存在raw/response.json,其结构为 `{"product":{"id":"123","name":"..."},"comments":[{"item":"123","id":"r1","text":"...","default":false,"sku":"..."}]}`;以下指针只是这个样例的路径:
+
+```json
+{
+  "schema": "ksf.voc-agent-batch.v1",
+  "batchId": "本批唯一编号",
+  "producer": "Agent名/规则版本",
+  "sources": [{"id":"run1","path":"raw/response.json","sha256":"真实文件64位SHA","platform":"jd","marketplace":"jd","requestItemId":"123","observedAt":null,"collectedAt":null}],
+  "products": [{"sourceId":"run1","pointer":"/product","fields":{"itemId":"/id","title":"/name"}}],
+  "reviews": [{"sourceId":"run1","pointer":"/comments/0","fields":{"itemId":"/item","reviewId":"/id","content":"/text","isDefault":"/default","purchasedSku":"/sku"}}],
+  "themes": []
+}
+```
+
+商品可选映射url/shopId/shopName/price/priceBasis/sourceTime;评价可选appendContent/rating/ratingScale/reviewDate/purchasedSku/isDefault/sourceTime。未知默认状态留null,不能为了进分析伪造false。requestItemId与实际评价itemId不符会保留错配状态;商品上下文请求ID不符则拒绝。sourceTime等来源时间有字段则取字段,没有则用明确的source.observedAt,不能用当前时刻补。
+
+Theme例:`{"reviewId":"jd:123:r1","theme":"portion","quote":"原评价中连续逐字句子","quoteField":"content"}`;重复review+theme或伪造引文会拒绝。不提供消费者个人身份、不声明点赞数;未来采集到评论点赞字段时先扩来源合同、再实现点赞排序。

+ 203 - 0
skills/ksf-voc-library/scripts/agent_batch.py

@@ -0,0 +1,203 @@
+"""Validate Agent source mappings, build a new private batch, never modify the active library.
+
+Only product/review intake is supported here. Existing archive ingestion remains
+the route for social and policy material. No network, credentials or paid calls.
+"""
+import argparse
+import collections
+import hashlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+import sqlite3
+import sys
+import tempfile
+import zipfile
+import voc_db
+from product_insights import ANNOTATION_DDL, THEMES, text_hash
+
+SCHEMA = 'ksf.voc-agent-batch.v1'
+PRODUCT_FIELDS = {'itemId': 'item_id', 'title': 'title', 'url': 'url', 'shopId': 'shop_id', 'shopName': 'shop_name',
+                  'price': 'price', 'priceBasis': 'price_type', 'sourceTime': 'source_time'}
+REVIEW_FIELDS = {'itemId': 'item_id', 'reviewId': 'review_id', 'content': 'content', 'appendContent': 'append_content',
+                 'rating': 'rating', 'ratingScale': 'rating_scale', 'reviewDate': 'review_date', 'purchasedSku': 'sku',
+                 'isDefault': 'is_default_text', 'sourceTime': 'source_time'}
+SOURCE_FIELDS = {'id', 'path', 'sha256', 'platform', 'marketplace', 'requestItemId', 'observedAt', 'collectedAt', 'url'}
+
+def require(value, code):
+    voc_db.require(value, code)
+
+def digest(p):
+    with Path(p).open('rb') as f: return hashlib.file_digest(f, 'sha256').hexdigest()
+
+def pointer(value, path):
+    require(isinstance(path, str) and (not path or path.startswith('/')), 'INVALID_JSON_POINTER')
+    for token in path.split('/')[1:]:
+        token = token.replace('~1', '/').replace('~0', '~')
+        if isinstance(value, list):
+            require(token.isdigit() and int(token) < len(value), 'SOURCE_POINTER_NOT_FOUND')
+            value = value[int(token)]
+        else:
+            require(isinstance(value, dict) and token in value, 'SOURCE_POINTER_NOT_FOUND')
+            value = value[token]
+    return value
+
+def identifier(value):
+    require(isinstance(value, str) and value.strip() == value and 0 < len(value) <= 200 and not any(ord(c) < 32 for c in value), 'ID_MUST_BE_NONEMPTY_STRING')
+    return value
+
+def load(batch_path):
+    path = Path(batch_path).absolute()
+    require(path.is_file() and not path.is_symlink(), 'BATCH_NOT_FOUND')
+    require(path.stat().st_size <= 20000000, 'BATCH_MANIFEST_TOO_LARGE')
+    batch = json.loads(path.read_text(encoding='utf-8-sig'))
+    require(isinstance(batch, dict) and not set(batch) - {'schema', 'batchId', 'producer', 'sources', 'products', 'reviews', 'themes'}, 'INVALID_BATCH_FIELDS')
+    require(batch.get('schema') == SCHEMA, 'UNSUPPORTED_BATCH_SCHEMA')
+    identifier(batch.get('batchId'))
+    require(isinstance(batch.get('producer'), str) and 0 < len(batch['producer']) <= 200, 'PRODUCER_REQUIRED')
+    require(isinstance(batch.get('sources'), list) and 0 < len(batch['sources']) <= 1000, 'SOURCES_REQUIRED')
+    sources, assets = {}, {}
+    for source in batch['sources']:
+        require(isinstance(source, dict) and not set(source) - SOURCE_FIELDS, 'INVALID_SOURCE_FIELDS')
+        sid = identifier(source.get('id'))
+        require(sid not in sources, 'DUPLICATE_SOURCE_ID')
+        rel = source.get('path')
+        require(isinstance(rel, str) and '\\' not in rel and ':' not in rel and not PurePosixPath(rel).is_absolute()
+                and '..' not in PurePosixPath(rel).parts and rel.startswith('raw/'), 'INVALID_SOURCE_PATH')
+        p = path.parent / rel
+        require(not any(x.is_symlink() for x in (p, *p.parents)) and p.resolve().is_relative_to(path.parent.resolve()) and p.is_file(), 'SOURCE_MISSING_OR_REDIRECTED')
+        require(p.stat().st_size <= 50000000, 'SOURCE_TOO_LARGE')
+        raw = p.read_bytes()
+        require(hashlib.sha256(raw).hexdigest() == source.get('sha256'), 'SOURCE_HASH_MISMATCH')
+        require(source.get('platform') in ('jd', 'taobao') and source.get('marketplace') in
+                (('jd',) if source['platform'] == 'jd' else ('tmall', 'taobao_unverified')), 'SOURCE_PLATFORM_MISMATCH')
+        for field in ('observedAt', 'collectedAt', 'url'):
+            require(source.get(field) is None or isinstance(source[field], str), 'INVALID_SOURCE_METADATA')
+        if source.get('requestItemId') is not None: identifier(source['requestItemId'])
+        sources[sid] = (source, json.loads(raw.decode('utf-8-sig')))
+        require(rel not in assets or assets[rel][1] == source['sha256'], 'SOURCE_PATH_HASH_CONFLICT')
+        assets[rel] = (p, source['sha256'])
+    result = {'products': [], 'reviews': [], 'social': [], 'documents': [], 'clauses': []}
+    identifiers = collections.defaultdict(set)
+    for entity, mapping, mandatory in [('products', PRODUCT_FIELDS, {'itemId', 'title'}), ('reviews', REVIEW_FIELDS, {'itemId', 'reviewId', 'content'})]:
+        rows = batch.get(entity, [])
+        require(isinstance(rows, list) and len(rows) <= 100000, 'INVALID_BATCH_ROWS')
+        for record in rows:
+            require(isinstance(record, dict) and set(record) == {'sourceId', 'pointer', 'fields'}, 'INVALID_RECORD_MAPPING')
+            require(record['sourceId'] in sources and isinstance(record['fields'], dict) and mandatory <= set(record['fields'])
+                    and not set(record['fields']) - set(mapping), 'INVALID_FIELD_MAPPING')
+            source, payload = sources[record['sourceId']]
+            original = pointer(payload, record['pointer'])
+            values = {name: pointer(original, loc) for name, loc in record['fields'].items()}
+            iid = identifier(values['itemId'])
+            require(all(value is None or isinstance(value, (str, int, float, bool)) or name == 'appendContent' for name, value in values.items()), 'INVALID_MAPPED_FIELD_TYPE')
+            require('isDefault' not in values or values['isDefault'] is None or type(values['isDefault']) is bool, 'DEFAULT_STATE_MUST_BE_BOOLEAN_OR_NULL')
+            require('appendContent' not in values or values['appendContent'] is None or isinstance(values['appendContent'], (str, dict, list)), 'INVALID_APPEND_CONTENT')
+            for field in ('title', 'content', 'purchasedSku', 'ratingScale', 'reviewDate', 'sourceTime', 'priceBasis', 'url', 'shopName'):
+                require(field not in values or values[field] is None or isinstance(values[field], str), 'TEXT_FIELD_REQUIRED')
+            if entity == 'products':
+                require(isinstance(values['title'], str) and bool(values['title'].strip()), 'PRODUCT_TITLE_REQUIRED')
+                key = source['platform'] + ':' + iid
+                require(source.get('requestItemId') in (None, iid), 'PRODUCT_REQUEST_ID_MISMATCH')
+            else:
+                key = source['platform'] + ':' + iid + ':' + identifier(values['reviewId'])
+                require(isinstance(values['content'], str), 'REVIEW_CONTENT_REQUIRED')
+            require(key not in identifiers[entity], 'DUPLICATE_RECORD_ID')
+            identifiers[entity].add(key)
+            row = {mapping[k]: v for k, v in values.items()}
+            row.update(platform=source['platform'], marketplace=source['marketplace'], source_file=source['path'],
+                       source_time=values.get('sourceTime', source.get('observedAt')), collected_at=source.get('collectedAt'),
+                       source_locator=record['pointer'], source_field_map=record['fields'], producer=batch['producer'])
+            if entity == 'reviews':
+                row.update(observed_item_id=source.get('requestItemId'),
+                           item_identity_match=(iid == source['requestItemId']) if source.get('requestItemId') else None,
+                           review_id_method='source_field')
+            result[entity].append(row)
+    require(result['products'], 'PRODUCT_CONTEXT_REQUIRED')
+    require(all(r['platform'] + ':' + r['item_id'] in identifiers['products'] for r in result['reviews']), 'REVIEW_PRODUCT_CONTEXT_MISSING')
+    themes = batch.get('themes', [])
+    require(isinstance(themes, list) and len(themes) <= 100000, 'INVALID_THEMES')
+    from product_insights import append_text
+    by_review = {r['platform'] + ':' + r['item_id'] + ':' + r['review_id']: r for r in result['reviews']}
+    seen = set()
+    for tag in themes:
+        require(isinstance(tag, dict) and set(tag) == {'reviewId', 'theme', 'quote', 'quoteField'}, 'INVALID_THEME_FIELDS')
+        require(tag['reviewId'] in by_review and tag['theme'] in THEMES and tag['quoteField'] in ('content', 'append')
+                and isinstance(tag['quote'], str) and 0 < len(tag['quote']) <= 500, 'INVALID_THEME_EVIDENCE')
+        key = tag['reviewId'], tag['theme']
+        require(key not in seen, 'DUPLICATE_REVIEW_THEME')
+        seen.add(key)
+        review = by_review[tag['reviewId']]
+        original = review['content'] if tag['quoteField'] == 'content' else append_text(voc_db.canonical(review.get('append_content')))
+        require(tag['quote'] in original, 'THEME_QUOTE_NOT_VERBATIM')
+    return path, batch, result, assets
+
+def build_batch(batch_path, output, apply=False):
+    path, batch, rows, assets = load(batch_path)
+    target = Path(output).absolute()
+    require(target.suffix == '.sqlite' and target != path and not target.exists() and not target.is_symlink(), 'NEW_DATABASE_TARGET_REQUIRED')
+    require(not any(p.is_symlink() for p in target.parents), 'REDIRECTED_TARGET')
+    archive = target.with_suffix('.source.zip')
+    require(not archive.exists() and not archive.is_symlink(), 'SOURCE_ARCHIVE_ALREADY_EXISTS')
+    receipt = {'ok': True, 'readOnly': not apply, 'applied': False, 'schema': SCHEMA, 'batchId': batch['batchId'],
+               'counts': {k: len(v) for k, v in rows.items()}, 'sourceFilesVerified': len(assets),
+               'themeAnnotations': len(batch.get('themes', [])), 'networkRequests': 0, 'productionImported': False,
+               'databaseWrites': 0, 'activeLibraryModified': False}
+    if not apply: return receipt
+    target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+    created = []
+    with tempfile.TemporaryDirectory(prefix='.agent-batch-', dir=target.parent) as temp:
+        temp = Path(temp)
+        packed, staged = temp / 'source.zip', temp / 'batch.sqlite'
+        with zipfile.ZipFile(packed, 'x', zipfile.ZIP_DEFLATED) as z:
+            def write(name, body):
+                info = zipfile.ZipInfo('agent-batch/' + name, (2026, 1, 1, 0, 0, 0))
+                info.external_attr = 0o100600 << 16
+                z.writestr(info, body, compress_type=zipfile.ZIP_DEFLATED)
+            for entity, filename in voc_db.PRIMARY.items():
+                write('01_最新底库/' + filename, '\n'.join(voc_db.canonical(r) for r in rows[entity]))
+            manifest = path.read_bytes()
+            require(json.loads(manifest.decode('utf-8-sig')) == batch, 'BATCH_CHANGED_DURING_BUILD')
+            write('batch.json', manifest)
+            for rel, (p, expected) in sorted(assets.items()):
+                raw = p.read_bytes()
+                require(hashlib.sha256(raw).hexdigest() == expected, 'SOURCE_CHANGED_DURING_BUILD')
+                write('02_采集档案/' + rel, raw)
+        built = voc_db.build(packed, staged)
+        if batch.get('themes'):
+            c = sqlite3.connect(staged); c.row_factory = sqlite3.Row
+            try:
+                c.executescript(ANNOTATION_DDL)
+                for tag in batch['themes']:
+                    r = c.execute('SELECT * FROM reviews WHERE id=?', (tag['reviewId'],)).fetchone()
+                    voc_db.insert(c, 'review_theme_annotations', {'id': voc_db.key(tag, batch['producer']), 'review_id': r['id'],
+                        'product_id': r['product_id'], 'theme_code': tag['theme'], 'quote': tag['quote'], 'quote_field': tag['quoteField'],
+                        'text_sha256': text_hash(r), 'method': 'agent', 'producer': batch['producer']})
+                generation = voc_db.key(built['generation'], digest(Path(__file__)), digest(Path(__file__).with_name('product_insights.py')), batch['themes'])
+                c.execute("UPDATE meta SET value=? WHERE key='generation'", (generation,)); c.commit()
+            finally: c.close()
+        from update_library import verified
+        meta = verified(staged)
+        try:
+            for src, dest in ((packed, archive), (staged, target)):
+                os.chmod(src, 0o600); os.link(src, dest); created.append((dest, digest(src)))
+        except Exception:
+            for p, h in reversed(created):
+                if p.is_file() and digest(p) == h: p.unlink()
+            raise
+    return {**receipt, 'readOnly': False, 'applied': True, 'databaseWrites': 'staging-only', 'generation': meta['generation'],
+            'database': str(target), 'databaseSha256': digest(target), 'sourceArchive': str(archive), 'archiveSha256': digest(archive)}
+
+def main():
+    p = argparse.ArgumentParser(description=__doc__)
+    p.add_argument('--batch', required=True); p.add_argument('--output', required=True)
+    p.add_argument('--apply', action='store_true')
+    args = p.parse_args()
+    try: print(json.dumps(build_batch(args.batch, args.output, args.apply), ensure_ascii=False, indent=2))
+    except Exception as e:
+        code = str(e) if isinstance(e, voc_db.LibraryError) else 'AGENT_BATCH_FAILED'
+        print(json.dumps({'ok': False, 'error': {'code': code}, 'productionImported': False}, ensure_ascii=False), file=sys.stderr)
+        return 1
+    return 0
+
+if __name__ == '__main__': raise SystemExit(main())

+ 117 - 0
skills/ksf-voc-library/scripts/product_insights.py

@@ -0,0 +1,117 @@
+"""Product-scoped, reproducible review statistics with traceable theme examples.
+
+Existing v2 snapshots need no migration. Optional Agent annotations are accepted
+only while their product/review/text fingerprint still matches the active data.
+"""
+import collections
+import hashlib
+import json
+import re
+import statistics
+import unicodedata
+
+VERSION = 'ksf.product-overview.v1'
+THEMES = {
+    'flavour': ('口味与调味', ('味道', '口味', '好吃', '难吃', '香味', '好香', '美味', '太咸', '偏咸', '太淡', '辣', '鲜香')),
+    'texture': ('面条口感', ('口感', '劲道', '筋道', 'q弹', '软硬', '嚼劲', '面条', '面饼')),
+    'portion': ('份量与饱腹', ('份量', '分量', '面量', '量少', '量小', '不够吃', '吃不饱', '吃得饱', '量有点少', '量太少')),
+    'value': ('价格与性价比', ('价格', '性价比', '便宜', '实惠', '划算', '太贵', '优惠', '物有所值')),
+    'preparation': ('食用便利', ('方便', '省事', '速食', '煮', '泡', '加热', '宵夜', '夜宵')),
+    'packaging': ('包装与完整性', ('包装', '破损', '漏', '碎', '压坏', '密封', '变形')),
+    'delivery': ('配送与服务', ('物流', '快递', '配送', '送货', '发货', '客服', '服务')),
+    'repurchase': ('复购与推荐', ('回购', '复购', '再次购买', '再买', '一直买', '多次购买', '推荐', '囤货')),
+}
+ANNOTATION_DDL = '''CREATE TABLE IF NOT EXISTS review_theme_annotations(
+ id TEXT PRIMARY KEY, review_id TEXT NOT NULL REFERENCES reviews(id), product_id TEXT NOT NULL REFERENCES products(id),
+ theme_code TEXT NOT NULL, quote TEXT NOT NULL, quote_field TEXT NOT NULL,
+ text_sha256 TEXT NOT NULL, method TEXT NOT NULL, producer TEXT NOT NULL,
+ UNIQUE(review_id,theme_code,method,producer,text_sha256));
+ CREATE INDEX IF NOT EXISTS idx_review_themes_product ON review_theme_annotations(product_id,review_id);'''
+
+def normalize(text):
+    return ' '.join(unicodedata.normalize('NFKC', str(text or '')).casefold().split())
+
+def text_hash(row):
+    # Bind annotations to the original separate text fields, including appended text.
+    return hashlib.sha256(json.dumps([row['content'], row['append_content_json']], ensure_ascii=False, separators=(',', ':')).encode()).hexdigest()
+
+def append_text(raw):
+    value = json.loads(raw or 'null')
+    if isinstance(value, str): return value
+    if isinstance(value, dict): return '\n'.join(value[k] for k in ('content', 'comment', 'appendComment') if isinstance(value.get(k), str))
+    if isinstance(value, list): return '\n'.join(append_text(json.dumps(v)) for v in value if isinstance(v, (str, dict)))
+    return ''
+
+def excerpt(text, terms):
+    start = next((normalize(text).find(t) for t in terms if t in normalize(text)), 0)
+    # Never mark a normalized match as a verbatim quote: select actual source text.
+    start = max(0, min(start - 24, len(text) - 130))
+    return text[start:start + 170].strip()
+
+def overview(c, product):
+    pid = product['id']
+    all_rows = [dict(r) for r in c.execute('SELECT * FROM reviews WHERE product_id=? ORDER BY id', (pid,))]
+    eligible = [r for r in all_rows if r['is_default'] == 0 and r['identity_state'] == 'matched' and r['category_state'] != 'conflict']
+    # Product overview describes the linked product; strict category analyses remain separate queries.
+    groups = collections.defaultdict(list)
+    for row in eligible:
+        key = normalize(row['content'] + '\n' + append_text(row['append_content_json']))
+        if key: groups[key].append(row)
+    annotations = collections.defaultdict(list)
+    if c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='review_theme_annotations'").fetchone():
+        for row in c.execute('SELECT * FROM review_theme_annotations WHERE product_id=? ORDER BY id', (pid,)):
+            annotations[row['review_id']].append(dict(row))
+    buckets = {code: {'id': code, 'label': label, 'recordCount': 0, 'distinctTextCount': 0, 'examples': [], 'methods': set()}
+               for code, (label, _) in THEMES.items()}
+    tagged = 0
+    for rows in groups.values():
+        matches = {}
+        for row in rows:
+            parts = {'content': row['content'], 'append': append_text(row['append_content_json'])}
+            for code, (_, terms) in THEMES.items():
+                field = next((field for field, text in parts.items() if any(term in normalize(text) for term in terms)), None)
+                if field:
+                    matches.setdefault(code, {})[row['id']] = (row, field, excerpt(parts[field], terms), 'keyword')
+            for a in annotations[row['id']]:
+                if (a['theme_code'] in THEMES and a['method'] == 'agent' and a['text_sha256'] == text_hash(row)
+                        and a['quote_field'] in parts and a['quote'] and a['quote'] in parts[a['quote_field']]):
+                    matches.setdefault(a['theme_code'], {})[row['id']] = (row, a['quote_field'], a['quote'], 'agent')
+        if matches: tagged += 1
+        for code, evidence in matches.items():
+            b = buckets[code]
+            b['distinctTextCount'] += 1
+            b['recordCount'] += len(evidence)
+            b['methods'].update(v[3] for v in evidence.values())
+            row, field, quote, method = sorted(evidence.values(), key=lambda v: (v[3] != 'agent', v[0]['id']))[0]
+            if len(b['examples']) < 3:
+                b['examples'].append({'reviewId': row['id'], 'productId': pid, 'sourceId': row['source_record_id'],
+                    'quote': quote, 'quoteField': field, 'method': method, 'purchasedSku': row['purchased_sku_text'], 'reviewDate': row['review_date_raw']})
+    themes = []
+    for b in sorted(buckets.values(), key=lambda b: (-b['distinctTextCount'], -b['recordCount'], b['id'])):
+        if b['distinctTextCount']:
+            b.update(share=round(b['distinctTextCount'] / len(groups) * 100, 1), methods=sorted(b['methods']))
+            themes.append(b)
+    ratings = collections.defaultdict(list)
+    for r in eligible:
+        if r['rating_scale'] and r['rating_raw'] is not None and str(r['rating_raw']).strip():
+            ratings[r['rating_scale']].append(str(r['rating_raw']))
+    distributions = []
+    for scale, values in sorted(ratings.items()):
+        numeric = [float(v) for v in values if re.fullmatch(r'\d+(?:\.\d+)?', v) and float(v) <= 100000]
+        distributions.append({'scale': scale, 'count': len(values),
+            'average': round(statistics.mean(numeric), 2) if len(numeric) == len(values) else None,
+            'distribution': [{'value': v, 'count': n} for v, n in sorted(collections.Counter(values).items())]})
+    price_groups = []
+    for group in c.execute('SELECT currency,price_basis,count(*) count,min(amount_minor) min,max(amount_minor) max FROM prices WHERE product_id=? AND amount_minor IS NOT NULL GROUP BY currency,price_basis ORDER BY count DESC,price_basis', (pid,)):
+        price_groups.append(dict(group))
+    dates = [r['review_calendar_date'] for r in eligible if r['review_calendar_date']]
+    return {'schema': VERSION, 'productId': pid,
+        'reviews': {'collected': len(all_rows), 'eligible': len(eligible), 'distinctTexts': len(groups),
+            'categoryConfirmed': sum(r['category_state'] == 'consistent' for r in eligible),
+            'excluded': len(all_rows) - len(eligible), 'unknownDates': sum(not r['review_calendar_date'] for r in eligible),
+            'firstDate': min(dates) if dates else None, 'lastDate': max(dates) if dates else None,
+            'rated': sum(r['count'] for r in distributions), 'ratingGroups': distributions},
+        'themes': themes, 'taggedDistinctTexts': tagged, 'untaggedDistinctTexts': len(groups) - tagged,
+        'themeBasis': 'distinct_review_texts', 'themeMethod': 'keyword_with_evidenced_agent_annotations',
+        'prices': price_groups, 'sampleScope': 'product_link_non_default_matched_no_sku_conflict',
+        'note': '仅统计该商品已收录评价;主题可重叠,按去重文本计数,关键词提及不代表好评或差评。'}

+ 181 - 0
skills/ksf-voc-library/scripts/update_library.py

@@ -0,0 +1,181 @@
+"""Operator-only batch merge into a new immutable generation, then atomic pointer switch.
+
+Incoming databases must first be built with the existing validated VOC importer.
+Default is a dry plan. Readers continue using the old generation until --apply succeeds.
+"""
+import sys
+sys.dont_write_bytecode=True
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import sqlite3
+import tempfile
+import uuid
+import voc_db
+import retrieval
+from product_insights import ANNOTATION_DDL
+
+ORDER=['batches','assets','collection_requests','brands','categories','source_records','source_links','products',
+       'classification_assertions','product_observations','specs','prices','price_sources','reviews','social_posts',
+       'social_brand_mentions','policy_documents','policy_clauses','ranking_signals','coverage_observations','quality_issues','review_theme_annotations']
+STABLE={'products','reviews','social_posts','policy_documents','policy_clauses'}
+def digest(p):
+    with Path(p).open('rb') as f:return hashlib.file_digest(f,'sha256').hexdigest()
+def canonical(v):return json.dumps(v,ensure_ascii=False,sort_keys=True,separators=(',',':'))
+def verified(p):
+    with voc_db.readonly(p) as c:
+        voc_db.require(c.execute('PRAGMA integrity_check').fetchone()[0]=='ok' and not c.execute('PRAGMA foreign_key_check').fetchall(),'DATABASE_CHECK_FAILED')
+        voc_db.require(c.execute('SELECT count(*) FROM retrieval_documents').fetchone()[0]==sum(c.execute('SELECT count(*) FROM '+t).fetchone()[0] for t in retrieval.ENTITIES.values()),'RETRIEVAL_COUNT_MISMATCH')
+        for table in ORDER:
+            if table=='review_theme_annotations':continue  # Optional, backward-compatible enrichment.
+            voc_db.require(c.execute('SELECT sql FROM sqlite_master WHERE type=? AND name=?',('table',table)).fetchone() is not None,'MISSING_TABLE:'+table)
+        return dict(c.execute('SELECT * FROM meta'))
+
+def merge(current,incoming,dest):
+    im=verified(incoming)
+    if not current:shutil.copyfile(incoming,dest)
+    else:
+        with voc_db.readonly(current) as old:
+            con=sqlite3.connect(dest);old.backup(con);con.close()
+    c=sqlite3.connect(dest);c.row_factory=sqlite3.Row;c.execute('PRAGMA foreign_keys=ON')
+    maps={};delta={'added':{},'changed':{},'duplicate':{}}
+    try:
+        c.executescript(ANNOTATION_DDL)
+        c.execute('CREATE TABLE IF NOT EXISTS source_origins(source_record_id TEXT NOT NULL REFERENCES source_records(id),archive_sha256 TEXT NOT NULL,PRIMARY KEY(source_record_id,archive_sha256))')
+        c.execute('CREATE TABLE IF NOT EXISTS record_versions(entity TEXT NOT NULL,record_id TEXT NOT NULL,batch_sha256 TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(entity,record_id,batch_sha256))')
+        baseline=dict(c.execute('SELECT * FROM meta'))
+        c.execute('INSERT OR IGNORE INTO source_origins SELECT id,? FROM source_records',(baseline['archive_sha256'],))
+        if current:
+            with voc_db.readonly(incoming) as inc:
+                for table in ORDER:
+                    if not inc.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",(table,)).fetchone():continue
+                    info=c.execute('PRAGMA table_info('+table+')').fetchall()
+                    pk=[r['name'] for r in sorted(info,key=lambda r:r['pk']) if r['pk']]
+                    fks=[dict(r) for r in c.execute('PRAGMA foreign_key_list('+table+')')]
+                    mapping={};maps[table]=mapping
+                    for original in inc.execute('SELECT * FROM '+table):
+                        row=dict(original)
+                        for fk in fks:
+                            value=row[fk['from']]
+                            if value is not None:row[fk['from']]=maps.get(fk['table'],{}).get(value,value)
+                        if table=='quality_issues' and row['entity_type']=='source':
+                            row['entity_id']=maps.get('source_records',{}).get(row['entity_id'],row['entity_id'])
+                        columns=list(row)
+                        old=c.execute('SELECT * FROM '+table+' WHERE '+' AND '.join(k+'=?' for k in pk),[row[k] for k in pk]).fetchone()
+                        if table=='batches' and old and old['archive_sha256']==row['archive_sha256']:
+                            mapping[original['id']]=old['id'];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
+                        # The same timestamped price observation may be re-exported under a new source locator.
+                        # Unknown-time observations only deduplicate with identical source bytes.
+                        if table=='prices' and not old:
+                            fields=[k for k in columns if k not in ('id','source_record_id','collected_at_raw','collected_at_utc')]
+                            sql='SELECT p.id FROM prices p JOIN source_records s ON s.id=p.source_record_id WHERE '+' AND '.join('p.'+k+' IS ?' for k in fields)
+                            vals=[row[k] for k in fields]
+                            if not row.get('source_time_raw'):
+                                sql+=' AND s.payload_sha256=(SELECT payload_sha256 FROM source_records WHERE id=?)';vals.append(row['source_record_id'])
+                            same_price=c.execute(sql+' LIMIT 1',vals).fetchone()
+                            if same_price:
+                                mapping[original['id']]=same_price['id'];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
+                        if table in ('brands','categories') and old:
+                            voc_db.require(dict(old)==row,'TAXONOMY_CONFLICT:'+table+':'+str(row[pk[0]]))
+                        same=old and all(old[k]==v for k,v in row.items() if k!='source_record_id')
+                        if same:
+                            delta['duplicate'][table]=delta['duplicate'].get(table,0)+1
+                            if len(pk)==1:mapping[original[pk[0]]]=row[pk[0]]
+                            continue
+                        if old and table not in STABLE:
+                            voc_db.require(len(pk)==1 and pk[0] in ('id','path','asset_path'),'CONFLICTING_RELATION:'+table)
+                            col=pk[0]
+                            if table=='assets':row[col]='batches/'+im['archive_sha256'][:16]+'/'+row[col]
+                            elif table=='collection_requests':raise ValueError('CONFLICTING_COLLECTION_REQUEST')
+                            else:row[col]=hashlib.sha256((im['generation']+':'+str(original[col])).encode()).hexdigest()
+                            existing=c.execute('SELECT * FROM '+table+' WHERE '+col+'=?',(row[col],)).fetchone()
+                            voc_db.require(existing is None or dict(existing)==row,'BATCH_ID_COLLISION')
+                            if existing:
+                                mapping[original[col]]=row[col];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
+                            old=None
+                        if old:
+                            for version,batch in [(dict(old),baseline['archive_sha256']),(row,im['archive_sha256'])]:
+                                c.execute('INSERT OR IGNORE INTO record_versions VALUES(?,?,?,?)',(table,str(row[pk[0]]),batch,canonical(version)))
+                            c.execute('UPDATE '+table+' SET '+','.join(k+'=?' for k in columns if k not in pk)+' WHERE '+' AND '.join(k+'=?' for k in pk),[row[k] for k in columns if k not in pk]+[row[k] for k in pk])
+                            delta['changed'][table]=delta['changed'].get(table,0)+1
+                        else:
+                            c.execute('INSERT INTO '+table+'('+','.join(columns)+') VALUES('+','.join('?' for _ in columns)+')',[row[k] for k in columns])
+                            delta['added'][table]=delta['added'].get(table,0)+1
+                        if len(pk)==1:mapping[original[pk[0]]]=row[pk[0]]
+                for sid in maps['source_records'].values():c.execute('INSERT OR IGNORE INTO source_origins VALUES(?,?)',(sid,im['archive_sha256']))
+            # Merge dictionaries from the actual validated rows, retaining prior definitions.
+            tax=json.loads(baseline['taxonomy'])
+            for key in ('brands','categories'):
+                known={r['code'] for r in tax[key]}
+                tax[key].extend(r for r in json.loads(im['taxonomy'])[key] if r['code'] not in known)
+            c.execute("UPDATE meta SET value=? WHERE key='taxonomy'",(canonical(tax),))
+            for name in ('retrieval_fts','retrieval_documents'):c.execute('DROP TABLE '+name)
+            retrieval.create_index(c)
+            generation=hashlib.sha256(canonical([baseline['generation'],im['generation'],digest(Path(__file__)),delta]).encode()).hexdigest()
+            c.execute("UPDATE meta SET value=? WHERE key='generation'",(generation,))
+        else:
+            delta['added']={t:c.execute('SELECT count(*) FROM '+t).fetchone()[0] for t in ORDER}
+        c.commit()
+    finally:c.close()
+    verified(dest)
+    return delta
+
+def publish(incoming,registry,apply=False,label='资料更新',rollback=None):
+    root=Path(registry).resolve();source=Path(incoming).resolve() if incoming else None
+    pointer=root/'current.json'
+    prior=json.loads(pointer.read_text(encoding='utf-8')) if pointer.exists() else None
+    if not apply:
+        if rollback:
+            voc_db.require(prior and any(x['generation']==rollback for x in prior['history']),'VERSION_NOT_FOUND')
+            return {'ok':True,'readOnly':True,'rollbackGeneration':rollback}
+        meta=verified(source)
+        return {'ok':True,'readOnly':True,'incomingGeneration':meta['generation'],'currentGeneration':prior['generation'] if prior else None,'mode':'merge-batch' if prior else 'initial','label':label}
+    root.mkdir(parents=True,exist_ok=True);os.chmod(root,0o700)
+    lock=root/'.update.lock';fd=os.open(lock,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
+    temp=None;tmp=None
+    try:
+        # Re-read after the exclusive lock. Query readers never need this lock.
+        prior=json.loads(pointer.read_text(encoding='utf-8')) if pointer.exists() else None
+        if rollback:
+            entry=next((x for x in (prior or {}).get('history',[]) if x['generation']==rollback),None)
+            voc_db.require(entry is not None,'VERSION_NOT_FOUND')
+            target=(root/entry['file']).resolve();voc_db.require(target.is_relative_to(root),'INVALID_VERSION_PATH')
+            voc_db.require(digest(target)==entry['databaseSha256'],'ROLLBACK_HASH_MISMATCH');verified(target)
+            result={**entry,'label':'回退至 '+rollback[:12],'updatedAt':voc_db.now(),'history':prior['history']}
+        else:
+            meta=verified(source)
+            if prior and any(x.get('incomingGeneration')==meta['generation'] for x in prior['history']):
+                return {'ok':True,'applied':False,'operation':'already-imported','generation':prior['generation']}
+            current=(root/prior['file']).resolve() if prior else None
+            if current:
+                voc_db.require(current.is_relative_to(root) and digest(current)==prior['databaseSha256'],'CURRENT_HASH_MISMATCH')
+            temp=root/('.build-'+str(uuid.uuid4())+'.sqlite')
+            delta=merge(current,source,temp)
+            finalmeta=verified(temp);generation=finalmeta['generation']
+            versions=root/'versions';versions.mkdir(exist_ok=True);os.chmod(versions,0o700)
+            target=versions/(generation+'.sqlite')
+            if target.exists():
+                voc_db.require(digest(target)==digest(temp),'VERSION_ALREADY_EXISTS_DIFFERENT_CONTENT');temp.unlink();temp=None
+            else:
+                os.chmod(temp,0o600);temp.rename(target);temp=None
+            names={'products':'商品','reviews':'评价','prices':'价格观察','specs':'规格','social_posts':'社媒内容','policy_documents':'政策文件','policy_clauses':'条款','ranking_signals':'榜单线索','coverage_observations':'覆盖记录'}
+            summary='、'.join(names.get(t,t)+'新增'+str(delta['added'].get(t,0))+'/变更'+str(delta['changed'].get(t,0)) for t in retrieval.ENTITIES.values() if delta['added'].get(t,0) or delta['changed'].get(t,0)) or '重复资料,无实体新增'
+            entry={'generation':generation,'incomingGeneration':meta['generation'],'file':target.relative_to(root).as_posix(),'databaseSha256':digest(target),'updatedAt':voc_db.now(),'label':label,'summary':summary,'delta':delta}
+            result={**entry,'history':[entry,*((prior or {}).get('history',[]))]}
+        tmp=root/('.pointer-'+str(uuid.uuid4())+'.json')
+        with tmp.open('x',encoding='utf-8') as f:f.write(canonical(result)+'\n');f.flush();os.fsync(f.fileno())
+        os.chmod(tmp,0o600);os.replace(tmp,pointer);tmp=None
+        return {'ok':True,'applied':True,**result}
+    finally:
+        os.close(fd);lock.unlink()
+        if temp and temp.exists():temp.unlink()
+        if tmp and tmp.exists():tmp.unlink()
+
+if __name__=='__main__':
+    p=argparse.ArgumentParser(description=__doc__);p.add_argument('--incoming');p.add_argument('--registry',required=True);p.add_argument('--label',default='资料更新');p.add_argument('--apply',action='store_true');p.add_argument('--rollback')
+    args=p.parse_args()
+    try:print(json.dumps(publish(args.incoming,args.registry,args.apply,args.label,args.rollback),ensure_ascii=False,indent=2))
+    except Exception as e:print(json.dumps({'ok':False,'error':str(e)},ensure_ascii=False),file=sys.stderr);sys.exit(1)

+ 2 - 1
src/core/library.js

@@ -2,12 +2,13 @@
 export const LIBRARY_ENTITIES={products:'商品与竞品',reviews:'电商评价',social:'社媒内容',documents:'政策文件',clauses:'条款与应用',prices:'价格快照',specs:'商品规格',rankings:'榜单线索',coverage:'采集覆盖',reports:'已有研究成果'};
 export const LIBRARY_GROUPS=[{name:'商品研究',icon:'inbox',entities:['products']},{name:'消费洞察',icon:'chat',entities:['social']},{name:'政策与膳食依据',icon:'shield',entities:['documents']},{name:'研究沉淀',icon:'doc',entities:['reports']}];
 export const PRODUCT_PANELS={overview:'商品概况',specs:'包装规格',prices:'价格记录',reviews:'消费者评价',rankings:'榜单线索'};
-const PRODUCT_QUERY_KEYS=['panel','relatedQ','relatedQuality','relatedCursor','relatedFrom','relatedTo'];
+const PRODUCT_QUERY_KEYS=['panel','relatedQ','relatedQuality','relatedCursor','relatedFrom','relatedTo','relatedReviewId'];
 export function libraryCatalogQuery(query={}){return Object.fromEntries(Object.entries(query).filter(([key])=>!PRODUCT_QUERY_KEYS.includes(key)));}
 export function libraryProductRequest(productId,query={}){
   const entity=Object.hasOwn(PRODUCT_PANELS,query.panel)?query.panel:'overview';
   if(entity==='overview')return null;
   const filters={productId};
+  if(entity==='reviews'&&query.relatedReviewId)filters.recordId=query.relatedReviewId;
   const retiredDates=['reviews','prices'].includes(entity)&&['relatedFrom','relatedTo'].some(key=>Object.hasOwn(query,key));
   return {op:'browse',entity,filters,quality:query.relatedQuality==='audit'?'audit':'analysis',limit:20,
     ...(query.relatedQ?{keyword:query.relatedQ}:{}),...(query.relatedCursor&&!retiredDates?{cursor:query.relatedCursor}:{})};

+ 77 - 4
src/pages/library-product-detail.js

@@ -32,7 +32,8 @@ export class LibraryProductDetail extends LitElement {
     for (const [key, value] of Object.entries(patch)) value == null || value === '' ? delete q[key] : q[key] = value;
     return libraryPath(q, { entity: 'products', id: this.product.id });
   }
-  _switch(panel) { navigate(this._url({ panel, relatedQ: null, relatedQuality: null, relatedFrom: null, relatedTo: null })); }
+  _switch(panel) { navigate(this._url({ panel, relatedQ: null, relatedQuality: null, relatedFrom: null, relatedTo: null, relatedReviewId: null })); }
+  _openReview(id) { navigate(this._url({ panel: 'reviews', relatedReviewId: id, relatedQ: null, relatedQuality: null })); }
   _patch(patch) { navigate(this._url(patch)); }
   _page(cursor, next = false) {
     if (next) this._previous.set(cursor, this.query.relatedCursor || '');
@@ -56,18 +57,37 @@ export class LibraryProductDetail extends LitElement {
   }
   _overview() {
     const facts = libraryRecordInfo(this.product, 'products', this.summary);
-    return html`<div class="overview-grid"><section class="panel product-info"><h3>商品信息</h3><dl class="facts">${facts.map(([label, value]) => html`<div class=${label === '店铺' ? 'wide-fact' : ''}><dt>${label}</dt><dd>${value}</dd></div>`)}</dl></section>
+    const overview = this.product.overview, reviews = overview?.reviews;
+    return html`${reviews ? html`<div class="overview-metrics" aria-label="商品评价综合数据">
+      ${[['已收录评价', reviews.collected, '全部收录记录'], ['可分析评价', reviews.eligible, '排除默认、错配及规格冲突'], ['去重评价文本', reviews.distinctTexts, '同文只计一次'], ['评价主题', overview.themes.length, '按提及文本数排序']].map(([label, value, hint]) => html`<div><span>${label}</span><strong>${number(value)}</strong><small>${hint}</small></div>`)}
+    </div>` : ''}<div class="overview-grid"><section class="panel product-info"><h3>商品信息</h3><dl class="facts">${facts.map(([label, value]) => html`<div class=${label === '店铺' ? 'wide-fact' : ''}><dt>${label}</dt><dd>${value}</dd></div>`)}</dl></section>
       <section class="panel"><h3>关联资料</h3><div class="product-resources">
         ${['specs', 'prices', 'reviews', 'rankings'].map(entity => html`<button data-panel-shortcut=${entity} @click=${() => this._switch(entity)}>
           <span>${icon(({ specs: 'table', prices: 'bars', reviews: 'chat', rankings: 'star' })[entity], 18)}${PRODUCT_PANELS[entity]}</span>
           <strong>${number(this._count(entity))}<small>条</small></strong>${icon('right', 12)}
         </button>`)}
-      </div><p class="hint">仅统计已收录资料,不代表平台全部资料或独立消费者人数。</p></section></div>`;
+      </div><p class="hint">仅统计已收录资料,不代表平台全部资料或独立消费者人数。</p></section></div>
+      ${overview ? this._insights(overview) : html`<p class="scope-note">评价概况暂未生成,可在消费者评价中查看已收录原文。</p>`}`;
+  }
+  _insights(overview) {
+    const r = overview.reviews, peak = overview.themes[0]?.distinctTextCount || 1;
+    return html`<div class="insights-grid"><section class="panel themes-panel"><div class="insight-heading"><h3>高频评价主题</h3><span>按提及次数</span></div>
+      <p class="hint">基于 ${number(r.distinctTexts)} 条去重文本;同条评价可提及多个主题。提及不代表好评或差评。</p>
+      ${overview.themes.length ? html`<div class="theme-list">${overview.themes.map((theme, index) => html`<details class="theme-item" ?open=${index === 0}>
+        <summary><span class="theme-label"><b>${index + 1}</b>${theme.label}</span><span class="theme-bar" aria-hidden="true"><i style=${`width:${theme.distinctTextCount / peak * 100}%`}></i></span><span class="theme-count">${number(theme.distinctTextCount)}<small>条 · ${theme.share}%</small></span>${icon('down', 11)}</summary>
+        <div class="theme-quotes">${theme.examples.slice(0, 2).map(example => html`<article><blockquote>${example.quote}</blockquote><div class="quote-meta"><span>${example.reviewDate ? libraryDate(example.reviewDate) : '评价日期未知'}${example.purchasedSku ? ` · ${example.purchasedSku}` : ''}</span><button class="text-button" @click=${() => this._openReview(example.reviewId)}>查看原评价 ${icon('right', 10)}</button></div></article>`)}</div>
+      </details>`)}</div>` : html`<div class="insight-empty">${r.eligible ? '暂未提取到明确主题,仍可查看评价原文。' : r.collected ? '已收录评价尚不满足分析条件,暂不生成高频结论。' : '这个商品暂未收录评价,采集后会生成概况。'}</div>`}
+      ${overview.untaggedDistinctTexts ? html`<p class="hint">另有 ${number(overview.untaggedDistinctTexts)} 条去重文本未归入以上主题。</p>` : ''}
+    </section><section class="panel aggregate-panel"><h3>综合观察</h3>
+      <div class="observation"><h4>评分分布</h4>${r.ratingGroups.length ? r.ratingGroups.map(group => html`<div class="rating-group"><div class="rating-heading"><span>${group.scale}</span>${group.average != null ? html`<strong>${group.average}<small>样本均分</small></strong>` : html`<small>${number(group.count)} 条</small>`}</div><div class="rating-distribution">${group.distribution.map(bucket => html`<span>${bucket.value}<i><b style=${`width:${bucket.count / group.count * 100}%`}></b></i><small>${number(bucket.count)}条</small></span>`)}</div></div>`) : html`<p class="hint">暂无可按统一体系统计的评分。</p>`}</div>
+      <div class="observation"><h4>价格观察</h4>${overview.prices.length ? overview.prices.map(price => html`<div class="price-observation"><span>${libraryLabel(price.price_basis, this.summary)}</span><strong>${price.currency === 'CNY' ? '¥' : `${price.currency || '币种未知'} `}${(price.min / 100).toFixed(2)}${price.min !== price.max ? `–${(price.max / 100).toFixed(2)}` : ''}</strong><small>${number(price.count)}次观察</small></div>`) : html`<p class="hint">暂无已记录金额的价格观察。</p>`}<p class="hint">不同价格口径分开统计,历史观察不代表当前实付价。</p></div>
+      <div class="observation sample-coverage"><h4>样本范围</h4><p>${r.firstDate ? `${r.firstDate} 至 ${r.lastDate}` : '评价时间范围未知'}${r.unknownDates ? ` · ${number(r.unknownDates)}条日期未知` : ''}</p><p>${number(r.categoryConfirmed)} 条购买规格与品类一致${r.excluded ? ` · ${number(r.excluded)}条未纳入分析` : ''}。</p><p>按本商品链接统计,不能推断平台全量、独立消费者人数或购买规格占比。</p></div>
+    </section></div>`;
   }
   _filters() {
     const reviews = this.panel === 'reviews';
     return html`<section class="panel section-tools"><div class="section-heading"><h3>${PRODUCT_PANELS[this.panel]}${this._data ? html`<span class="result-count">${number(this._data.totalMatched)}<small>条</small></span>` : ''}</h3>
-      ${reviews ? html`<label class="quality-label">查看范围<select aria-label="商品评价范围" .value=${this.query.relatedQuality || 'analysis'} @change=${e => this._patch({ relatedQuality: e.target.value })}><option value="analysis">推荐用于分析</option><option value="audit">全部评价(含待确认)</option></select></label>` : ''}
+      ${reviews ? html`<div class="review-controls">${this.query.relatedReviewId ? html`<button class="text-button" @click=${() => this._patch({ relatedReviewId: null })}>查看全部评价</button>` : ''}<label class="quality-label">查看范围<select aria-label="商品评价范围" .value=${this.query.relatedQuality || 'analysis'} @change=${e => this._patch({ relatedQuality: e.target.value })}><option value="analysis">推荐用于分析</option><option value="audit">全部评价(含待确认)</option></select></label></div>` : ''}
     </div>
     </section>`;
   }
@@ -149,6 +169,50 @@ export class LibraryProductDetail extends LitElement {
     .product-resources button>span svg{width:14px;height:14px;flex:none}
     .product-resources button>strong{display:flex;align-items:baseline;gap:3px;margin-left:auto;font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}
     .product-resources small{font-size:10px;font-weight:400;color:var(--kf-ink-3)}
+    .overview-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-bottom:1px solid var(--kf-line-soft)}
+    .overview-metrics>div{display:grid;gap:3px;padding:12px 14px;min-width:0}
+    .overview-metrics>div+div{border-left:1px solid var(--kf-line-soft)}
+    .overview-metrics span{font-size:11px;color:var(--kf-ink-3)}
+    .overview-metrics strong{font-size:26px;font-weight:600;letter-spacing:-.6px;color:var(--kf-ink);font-variant-numeric:tabular-nums;line-height:1.25}
+    .overview-metrics small{font-size:10px;color:var(--kf-ink-3);line-height:1.6}
+    .insights-grid{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);border-top:1px solid var(--kf-line-soft)}
+    .aggregate-panel{border-left:1px solid var(--kf-line-soft)}
+    .insight-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}
+    .insight-heading>span{font-size:10px;color:var(--kf-ink-3)}
+    .theme-list{margin-top:9px}
+    .theme-item{border-top:1px solid var(--kf-line-soft)}
+    .theme-item summary{display:flex;align-items:center;gap:8px;padding:9px 0;list-style:none;cursor:pointer}
+    .theme-item summary::-webkit-details-marker{display:none}
+    .theme-label{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--kf-ink-2);flex:none}
+    .theme-label b{font-size:10px;color:var(--kf-ink-3);width:12px;font-weight:500}
+    .theme-bar{flex:1;min-width:12px;height:4px;background:var(--kf-fill);border-radius:4px;overflow:hidden;margin:0 4px}
+    .theme-bar i{display:block;height:100%;background:var(--kf-accent);opacity:.65;border-radius:4px}
+    .theme-count{font-size:12px;font-weight:600;color:var(--kf-ink-2);font-variant-numeric:tabular-nums;white-space:nowrap}
+    .theme-count small{font-size:10px;font-weight:400;margin-left:4px;color:var(--kf-ink-3)}
+    .theme-quotes{display:grid;grid-template-columns:minmax(0,1fr);gap:7px;padding-bottom:9px;min-width:0}
+    .theme-quotes article{min-width:0;padding:9px 10px;border-radius:6px;background:var(--kf-surface-raised)}
+    blockquote{margin:0;font-size:12px;line-height:1.8;color:var(--kf-ink-2);overflow-wrap:anywhere;white-space:pre-wrap}
+    .quote-meta{display:flex;align-items:center;gap:6px;justify-content:space-between;margin-top:4px}
+    .quote-meta>span{min-width:0;flex:1;font-size:10px;color:var(--kf-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+    .quote-meta button{flex:none;white-space:nowrap;padding:0;min-height:26px}
+    .observation{padding:11px 0;border-bottom:1px solid var(--kf-line-soft)}
+    .observation:last-child{border-bottom:0;padding-bottom:0}
+    .observation h4{font-size:11px;color:var(--kf-ink-3);margin-bottom:7px;font-weight:500}
+    .rating-group+.rating-group{margin-top:9px}
+    .rating-heading{display:flex;align-items:baseline;justify-content:space-between;gap:8px;color:var(--kf-ink-2);font-size:11px}
+    .rating-heading strong{font-size:19px;color:var(--kf-warn);font-weight:600}
+    .rating-heading small{font-size:10px;font-weight:400;color:var(--kf-ink-3);margin-left:5px}
+    .rating-distribution{display:flex;gap:4px 12px;flex-wrap:wrap;margin-top:6px}
+    .rating-distribution>span{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--kf-ink-2);min-width:80px;flex:1}
+    .rating-distribution i{width:35px;height:4px;background:var(--kf-fill);border-radius:3px;overflow:hidden}
+    .rating-distribution b{display:block;height:100%;background:var(--kf-warn)}
+    .rating-distribution small{font-size:10px;color:var(--kf-ink-3)}
+    .price-observation{display:flex;gap:6px;align-items:baseline;flex-wrap:wrap;font-size:11px;line-height:1.8;color:var(--kf-ink-2)}
+    .price-observation strong{margin-left:auto;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums}
+    .price-observation small{font-size:10px;color:var(--kf-ink-3)}
+    .sample-coverage p{font-size:11px;line-height:1.8;color:var(--kf-ink-3)}
+    .insight-empty{padding:22px 0;font-size:12px;line-height:1.8;color:var(--kf-ink-3)}
+    .review-controls{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
 
     .section-tools{border-bottom:1px solid var(--kf-line-soft);padding:9px 14px}
     .section-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}
@@ -200,6 +264,8 @@ export class LibraryProductDetail extends LitElement {
     .error{color:var(--kf-ink-2)}
 
     @media(max-width:1000px){
+      .insights-grid{grid-template-columns:1fr}
+      .aggregate-panel{border-left:0;border-top:1px solid var(--kf-line-soft)}
       .overview-grid{grid-template-columns:1fr}
       .overview-grid>.panel+ .panel{border-left:0;border-top:1px solid var(--kf-line-soft)}
       .product-resources{grid-template-columns:repeat(4,minmax(0,1fr))}
@@ -210,6 +276,13 @@ export class LibraryProductDetail extends LitElement {
       .product-tabs button{flex:none}
     }
     @media(max-width:600px){
+      .overview-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}
+      .overview-metrics>div{padding:10px 12px}
+      .overview-metrics>div:nth-child(3){border-left:0;border-top:1px solid var(--kf-line-soft)}
+      .overview-metrics>div:nth-child(4){border-top:1px solid var(--kf-line-soft)}
+      .overview-metrics strong{font-size:23px}
+      .theme-item summary{gap:5px}
+      .theme-label{font-size:11px;gap:4px}
       .product-tabs{gap:2px;padding:4px}
       .product-tabs button{min-height:38px;font-size:11px;padding:7px 9px;gap:5px}
       .product-tabs span{font-size:10px}

+ 8 - 0
test/library.test.js

@@ -4,6 +4,14 @@ import { libraryRequest,libraryPath,libraryLabel,libraryRecordInfo,libraryProduc
 import { readLibrary } from '../src/services/library-service.js';
 import { createLibraryApi } from '../server/library-api.mjs';
 import { Readable } from 'node:stream';
+
+test('主题引文打开固定商品下的精确评价,不继承其他商品或列表条件',()=>{
+  const request=libraryProductRequest('jd:123',{panel:'reviews',relatedReviewId:'jd:123:r1',platform:'tmall',brand:'other',q:'旧条件'});
+  assert.deepEqual(request.filters,{productId:'jd:123',recordId:'jd:123:r1'});
+  assert.equal(request.keyword,undefined);
+  assert.deepEqual(libraryProductRequest('jd:123',{panel:'prices',relatedReviewId:'jd:123:r1'}).filters,{productId:'jd:123'});
+  assert.deepEqual(libraryCatalogQuery({brand:'ksf',panel:'reviews',relatedReviewId:'jd:123:r1'}),{brand:'ksf'});
+});
 test('资料条件同时保留,未知字段和日期不被关键词替代',()=>{
   const r=libraryRequest({type:'reviews',platform:'jd',brand:'ksf',category:'banmian',q:'份量',from:'2026-10-01',to:'2026-11-05',shopId:'__unknown'});
   assert.deepEqual(r.filters,{platform:'jd',brand:'ksf',category:'banmian',shopId:{exists:false},reviewDate:{min:'2026-10-01',max:'2026-11-05'}});