first commit
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
获取最近 N 条听记的 AI 摘要并合并输出
|
||||
|
||||
用法:
|
||||
python minutes_recent_summary.py # 最近 5 条
|
||||
python minutes_recent_summary.py --max 10 # 最近 10 条
|
||||
python minutes_recent_summary.py --output summary.md
|
||||
python minutes_recent_summary.py --dry-run
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import argparse
|
||||
from typing import List, Any, Optional, Tuple
|
||||
|
||||
|
||||
class DWSCommandError(RuntimeError):
|
||||
"""DWS 没有返回可用 JSON;调用方不得把它解释成空业务结果。"""
|
||||
|
||||
|
||||
def _unwrap_rows(payload: Any) -> List[Any]:
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
for key in ('result', 'data', 'list'):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
for inner_key in (
|
||||
'itemList', 'items', 'list', 'records', 'minutes',
|
||||
):
|
||||
inner = value.get(inner_key)
|
||||
if isinstance(inner, list):
|
||||
return inner
|
||||
return []
|
||||
|
||||
|
||||
def uuid_title_pairs_from_payload(
|
||||
payload: Any,
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""列表项可为对象、JSON 字符串、或纯 taskUuid 字符串。"""
|
||||
out: List[Tuple[str, str]] = []
|
||||
for item in _unwrap_rows(payload):
|
||||
if isinstance(item, dict):
|
||||
uuid = (
|
||||
item.get('taskUuid')
|
||||
or item.get('id')
|
||||
or item.get('task_uuid')
|
||||
)
|
||||
if not uuid:
|
||||
continue
|
||||
title = item.get('title') or item.get('name') or '无标题'
|
||||
if not isinstance(uuid, (str, int, float, bool)):
|
||||
continue
|
||||
if not isinstance(title, (str, int, float, bool)):
|
||||
title = str(title) if isinstance(title, dict) else '无标题'
|
||||
out.append((str(uuid), str(title)))
|
||||
elif isinstance(item, str):
|
||||
text = item.strip()
|
||||
if not text:
|
||||
continue
|
||||
if text.startswith('{'):
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(parsed, dict):
|
||||
continue
|
||||
uuid = (
|
||||
parsed.get('taskUuid')
|
||||
or parsed.get('id')
|
||||
or parsed.get('task_uuid')
|
||||
)
|
||||
if not uuid:
|
||||
continue
|
||||
title = parsed.get('title') or parsed.get('name') or '无标题'
|
||||
out.append((str(uuid), str(title)))
|
||||
else:
|
||||
out.append((text, text))
|
||||
return out
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
raise DWSCommandError(str(exc)) from exc
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or f"退出码 {result.returncode}"
|
||||
raise DWSCommandError(detail)
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise DWSCommandError(f"DWS 返回的不是合法 JSON:{exc}") from exc
|
||||
|
||||
|
||||
def summary_text_from_payload(payload: Any) -> str:
|
||||
"""兼容当前 Runtime 的 result.fullSummary 与历史直接字段。"""
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
if not isinstance(payload, dict):
|
||||
return ''
|
||||
inner = payload.get('result', payload)
|
||||
if isinstance(inner, str):
|
||||
return inner
|
||||
if not isinstance(inner, dict):
|
||||
return ''
|
||||
value = (inner.get('fullSummary') or inner.get('summary')
|
||||
or inner.get('content'))
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if value is not None:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return json.dumps(inner, ensure_ascii=False)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='获取最近听记的 AI 摘要'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--max', type=int, default=5, help='获取条数 (默认 5)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output', default='', help='输出到 Markdown 文件'
|
||||
)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
print('🎙️ 获取听记列表...')
|
||||
list_data = run_dws([
|
||||
'minutes', 'list', 'mine',
|
||||
'--max', str(args.max),
|
||||
'--format', 'json',
|
||||
], dry_run=args.dry_run)
|
||||
|
||||
if args.dry_run:
|
||||
run_dws([
|
||||
'minutes', 'get', 'summary',
|
||||
'--id', '<TASK_UUID>', '--format', 'json',
|
||||
], dry_run=True)
|
||||
return
|
||||
|
||||
if not list_data:
|
||||
print('未找到听记')
|
||||
return
|
||||
|
||||
pairs = uuid_title_pairs_from_payload(list_data)
|
||||
if not pairs:
|
||||
print('暂无听记')
|
||||
return
|
||||
|
||||
output_lines = [f"# 最近 {len(pairs)} 条听记摘要\n"]
|
||||
for i, (uuid, title) in enumerate(pairs, 1):
|
||||
print(f" [{i}/{len(pairs)}] 获取摘要: {title}")
|
||||
|
||||
summary_data = run_dws([
|
||||
'minutes', 'get', 'summary',
|
||||
'--id', uuid, '--format', 'json',
|
||||
])
|
||||
summary_text = summary_text_from_payload(summary_data)
|
||||
|
||||
output_lines.append(f"## {i}. {title}\n")
|
||||
if summary_text:
|
||||
output_lines.append(f"{summary_text}\n")
|
||||
else:
|
||||
output_lines.append("(暂无摘要)\n")
|
||||
|
||||
full_output = '\n'.join(output_lines)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
f.write(full_output)
|
||||
print(f"\n✓ 已输出到 {args.output}")
|
||||
else:
|
||||
print('\n' + full_output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except DWSCommandError as exc:
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Minutes 示例脚本的离线 Contract 回归测试。"""
|
||||
|
||||
import io
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
SKILL_DIR = SCRIPTS_DIR.parent
|
||||
SKILL_FILE = SKILL_DIR / 'SKILL.md'
|
||||
REFERENCES_DIR = SKILL_DIR / 'references'
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import minutes_recent_summary
|
||||
from minutes_recent_summary import uuid_title_pairs_from_payload
|
||||
|
||||
|
||||
class MinutesScriptContractTest(unittest.TestCase):
|
||||
def test_reference_graph_has_no_dead_or_orphaned_files(self):
|
||||
markdown_files = [SKILL_FILE, *sorted(REFERENCES_DIR.rglob('*.md'))]
|
||||
markdown_link = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
|
||||
graph = {path.resolve(): set() for path in markdown_files}
|
||||
linked_scripts = set()
|
||||
|
||||
for source in markdown_files:
|
||||
for raw_target in markdown_link.findall(
|
||||
source.read_text(encoding='utf-8')
|
||||
):
|
||||
target = raw_target.split('#', 1)[0].strip()
|
||||
if not target or '://' in target or target.startswith('mailto:'):
|
||||
continue
|
||||
resolved = (source.parent / target).resolve()
|
||||
self.assertTrue(
|
||||
resolved.exists(),
|
||||
f'dead link: {source.relative_to(SKILL_DIR)} -> {target}',
|
||||
)
|
||||
if resolved in graph:
|
||||
graph[source.resolve()].add(resolved)
|
||||
if resolved.parent == SCRIPTS_DIR.resolve():
|
||||
linked_scripts.add(resolved)
|
||||
|
||||
reachable = set()
|
||||
pending = [SKILL_FILE.resolve()]
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current in reachable:
|
||||
continue
|
||||
reachable.add(current)
|
||||
pending.extend(graph.get(current, ()))
|
||||
|
||||
orphaned = sorted(
|
||||
str(path.relative_to(SKILL_DIR))
|
||||
for path in graph
|
||||
if path != SKILL_FILE.resolve() and path not in reachable
|
||||
)
|
||||
self.assertEqual(orphaned, [], f'orphan references: {orphaned}')
|
||||
|
||||
production_scripts = {
|
||||
path.resolve()
|
||||
for path in SCRIPTS_DIR.glob('*.py')
|
||||
if not path.name.startswith('test_')
|
||||
}
|
||||
missing_scripts = sorted(
|
||||
path.name for path in production_scripts - linked_scripts
|
||||
)
|
||||
self.assertEqual(
|
||||
missing_scripts, [],
|
||||
f'production scripts missing from references: {missing_scripts}',
|
||||
)
|
||||
|
||||
def test_list_parser_accepts_runtime_item_list(self):
|
||||
payload = {
|
||||
'result': {
|
||||
'itemList': [{'taskUuid': 'u1', 'title': '周会'}],
|
||||
},
|
||||
}
|
||||
self.assertEqual(
|
||||
uuid_title_pairs_from_payload(payload), [('u1', '周会')]
|
||||
)
|
||||
|
||||
def test_summary_accepts_runtime_full_summary(self):
|
||||
payload = {'result': {'fullSummary': '完整摘要'}}
|
||||
self.assertEqual(
|
||||
minutes_recent_summary.summary_text_from_payload(payload),
|
||||
'完整摘要',
|
||||
)
|
||||
|
||||
def test_dws_failure_is_not_treated_as_empty_result(self):
|
||||
failed = subprocess.CompletedProcess(
|
||||
args=['dws'], returncode=2, stdout='', stderr='boom'
|
||||
)
|
||||
with mock.patch.object(
|
||||
minutes_recent_summary.subprocess, 'run', return_value=failed
|
||||
):
|
||||
with self.assertRaises(minutes_recent_summary.DWSCommandError):
|
||||
minutes_recent_summary.run_dws(['minutes', 'list', 'mine'])
|
||||
|
||||
def test_summary_dry_run_never_starts_subprocess(self):
|
||||
argv = ['minutes_recent_summary.py', '--max', '2', '--dry-run']
|
||||
with mock.patch.object(sys, 'argv', argv):
|
||||
with mock.patch.object(
|
||||
minutes_recent_summary.subprocess,
|
||||
'run',
|
||||
side_effect=AssertionError('dry-run called subprocess'),
|
||||
):
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output):
|
||||
minutes_recent_summary.main()
|
||||
rendered = output.getvalue()
|
||||
self.assertIn('dws minutes list mine --max 2', rendered)
|
||||
self.assertIn(
|
||||
'dws minutes get summary --id <TASK_UUID>', rendered
|
||||
)
|
||||
|
||||
def test_phase2_execution_capsules_and_capability_boundaries(self):
|
||||
skill = SKILL_FILE.read_text(encoding='utf-8')
|
||||
intent_guide = (REFERENCES_DIR / 'intent-guide.md').read_text(
|
||||
encoding='utf-8'
|
||||
)
|
||||
minutes_reference = (REFERENCES_DIR / 'minutes.md').read_text(
|
||||
encoding='utf-8'
|
||||
)
|
||||
workflow_reference = (REFERENCES_DIR / '07-minutes.md').read_text(
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
for command in (
|
||||
'dws minutes +search --query "<关键词>" --scope all --page-all',
|
||||
'dws minutes +list-mine --page-all --format json',
|
||||
'dws minutes +list-shared --page-all --format json',
|
||||
'dws minutes +list-all --page-all --format json',
|
||||
'dws minutes +export-pack --id <taskUuid>',
|
||||
'dws minutes record start --dry-run --format json',
|
||||
):
|
||||
self.assertIn(command, skill)
|
||||
self.assertNotIn('+record-start --dry-run', skill)
|
||||
|
||||
self.assertIn('--pair "旧词1=>新词1"', intent_guide)
|
||||
self.assertIn('total` 是替换规则数', intent_guide)
|
||||
self.assertIn('minutes tag query --tag-id <tagId>', intent_guide)
|
||||
self.assertNotIn('+replace-batch ...', intent_guide)
|
||||
self.assertNotIn('minutes tag ...', intent_guide)
|
||||
|
||||
self.assertIn('无公开 `permission list/get/inspect` 命令', minutes_reference)
|
||||
self.assertIn('`0` | 管理员', minutes_reference)
|
||||
self.assertIn('`4` | 仅查看', minutes_reference)
|
||||
self.assertIn('verification.mode=write_ack_only', minutes_reference)
|
||||
self.assertIn('dws minutes +share --id <taskUuid>', workflow_reference)
|
||||
self.assertIn('dws minutes +unshare --id <taskUuid>', workflow_reference)
|
||||
|
||||
def test_phase3_bad_case_guidance_is_general_and_executable(self):
|
||||
skill = SKILL_FILE.read_text(encoding='utf-8')
|
||||
intent_guide = (REFERENCES_DIR / 'intent-guide.md').read_text(
|
||||
encoding='utf-8'
|
||||
)
|
||||
minutes_reference = (REFERENCES_DIR / 'minutes.md').read_text(
|
||||
encoding='utf-8'
|
||||
)
|
||||
workflow_reference = (REFERENCES_DIR / '07-minutes.md').read_text(
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
for fact in (
|
||||
'--direction 1',
|
||||
'等我确认后再继续',
|
||||
'dws minutes +detail --ids <uuid1,uuid2> --artifacts basic',
|
||||
'不能用当前 profile 的组织名代替每条听记的归属',
|
||||
'不得为了验证预览而执行真实写入后再还原',
|
||||
'纯能力、规则或错误说明',
|
||||
'requested/resolved/missing/artifacts/status',
|
||||
'只有 `title/basic` 时只列元数据',
|
||||
):
|
||||
self.assertIn(fact, skill)
|
||||
|
||||
self.assertIn('不查询 Help、不编造替换词', intent_guide)
|
||||
self.assertIn('逐来源证据台账', minutes_reference)
|
||||
self.assertIn(
|
||||
'不能生成摘要、关键词、主题或内容分类',
|
||||
minutes_reference,
|
||||
)
|
||||
self.assertIn(
|
||||
'dws minutes +update --id <taskUuid> --title "<目标标题>" --dry-run',
|
||||
minutes_reference,
|
||||
)
|
||||
self.assertIn('当前 profile 的 `corpName`', minutes_reference)
|
||||
self.assertIn(
|
||||
'dws minutes hot-word list --format json', workflow_reference
|
||||
)
|
||||
self.assertIn(
|
||||
'dws minutes +list-mine --page-all --format json',
|
||||
workflow_reference,
|
||||
)
|
||||
self.assertIn('不能通过真实 create 后 cancel 来伪造预览', workflow_reference)
|
||||
for case_id in (
|
||||
'0052',
|
||||
'0060',
|
||||
'0066',
|
||||
'0068',
|
||||
'0070',
|
||||
'0071',
|
||||
'0110',
|
||||
'0121',
|
||||
'0132',
|
||||
'0136',
|
||||
'0137',
|
||||
'0142',
|
||||
'0145',
|
||||
):
|
||||
self.assertNotIn(case_id, skill)
|
||||
self.assertNotIn(case_id, intent_guide)
|
||||
self.assertNotIn(case_id, minutes_reference)
|
||||
self.assertNotIn(case_id, workflow_reference)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user