From cf10ab64732fc161f60cadf5929c9c914cba33f9 Mon Sep 17 00:00:00 2001 From: hmo Date: Wed, 11 Feb 2026 22:02:47 +0800 Subject: [PATCH] Initial commit to git.yoin --- README.md | 26 + csv-data-summarizer/README.md | 198 ++++ csv-data-summarizer/SKILL.md | 148 +++ csv-data-summarizer/analyze.py | 182 ++++ .../examples/showcase_financial_pl_data.csv | 46 + csv-data-summarizer/requirements.txt | 4 + csv-data-summarizer/resources/README.md | 83 ++ csv-data-summarizer/resources/sample.csv | 22 + deep-research/README.md | 18 + deep-research/SKILL.md | 346 +++++++ deep-research/scripts/format_docx.py | 332 +++++++ image-service/README.md | 27 + image-service/SKILL.md | 132 +++ image-service/config/settings.json | 42 + image-service/docs/api-reference.md | 233 +++++ image-service/docs/prompt-guide.md | 215 +++++ image-service/references/color-sync-guide.md | 76 ++ image-service/references/long-image-guide.md | 135 +++ .../references/text-rendering-guide.md | 41 + image-service/scripts/image_to_image.py | 273 ++++++ image-service/scripts/image_to_text.py | 287 ++++++ image-service/scripts/merge_long_image.py | 251 ++++++ image-service/scripts/research_image.py | 140 +++ image-service/scripts/text_to_image.py | 350 ++++++++ log-analyzer/README.md | 20 + log-analyzer/SKILL.md | 109 +++ log-analyzer/scripts/preprocess.py | 849 ++++++++++++++++++ mcp-builder/SKILL.md | 326 +++++++ searchnews/README.md | 21 + searchnews/SKILL.md | 376 ++++++++ searchnews/references/keywords.md | 31 + searchnews/scripts/ralph/prd.json | 39 + searchnews/scripts/ralph/prd.template.json | 12 + searchnews/scripts/ralph/progress.txt | 16 + searchnews/scripts/ralph/ralph.sh | 21 + searchnews/templates/README.md | 50 ++ searchnews/templates/blackboard_chalk.png | Bin 0 -> 1497061 bytes skill-creator/SKILL.md | 209 +++++ skill-creator/scripts/init_skill.py | 108 +++ skill-creator/scripts/package_skill.py | 138 +++ smart-query/README.md | 37 + smart-query/SKILL.md | 108 +++ smart-query/assets/.gitkeep | 0 smart-query/config/settings.json | 21 + smart-query/config/settings.json.example | 21 + smart-query/references/schema.md | 3 + smart-query/scripts/db_connector.py | 124 +++ smart-query/scripts/query.py | 107 +++ smart-query/scripts/schema_loader.py | 111 +++ story-to-scenes/SKILL.md | 317 +++++++ story-to-scenes/assets/.gitkeep | 0 .../assets/templates/characters_template.md | 53 ++ .../assets/templates/gallery_template.md | 60 ++ .../assets/templates/progress_template.json | 38 + .../references/prompt_templates.md | 157 ++++ story-to-scenes/references/style_presets.md | 235 +++++ uni-agent/README.md | 189 ++++ uni-agent/SKILL.md | 279 ++++++ uni-agent/adapters/__init__.py | 60 ++ uni-agent/adapters/a2a.py | 225 +++++ uni-agent/adapters/agent_protocol.py | 211 +++++ uni-agent/adapters/aitp.py | 217 +++++ uni-agent/adapters/anp.py | 191 ++++ uni-agent/adapters/base.py | 120 +++ uni-agent/adapters/lmos.py | 215 +++++ uni-agent/adapters/mcp.py | 159 ++++ uni-agent/config/agents.yaml | 121 +++ uni-agent/requirements.txt | 14 + uni-agent/scripts/test_adapters.py | 282 ++++++ uni-agent/scripts/test_all.py | 368 ++++++++ uni-agent/scripts/uni_cli.py | 257 ++++++ uni-agent/setup.sh | 107 +++ uni-agent/test_servers/a2a_server.py | 165 ++++ .../test_servers/agent_protocol_server.py | 146 +++ uni-agent/test_servers/aitp_server.py | 160 ++++ uni-agent/test_servers/lmos_server.py | 169 ++++ uni-agent/test_servers/mcp_server.py | 161 ++++ video-creator/README.md | 24 + video-creator/SKILL.md | 316 +++++++ video-creator/assets/.gitkeep | 0 video-creator/assets/bgm_epic.mp3 | Bin 0 -> 3734883 bytes video-creator/assets/bgm_technology.mp3 | Bin 0 -> 4001541 bytes video-creator/assets/default_config.yaml | 73 ++ video-creator/assets/example_config.yaml | 73 ++ video-creator/assets/logo.jpg | Bin 0 -> 39776 bytes .../assets/media/texts/25a546dbcb230f7f.svg | 54 ++ .../assets/media/texts/60350aa7fd09283e.svg | 54 ++ .../assets/media/texts/6bc75dd1367e9f40.svg | 54 ++ .../assets/media/texts/94ee77f0d4c0bc26.svg | 54 ++ .../assets/media/texts/9fded5d4bc94afcd.svg | 54 ++ .../assets/media/texts/a47357e721583a39.svg | 54 ++ .../2933822066_4135568850_2738303351.mp4 | Bin 0 -> 39364 bytes .../2933822066_4277570474_1431002730.mp4 | Bin 0 -> 45285 bytes .../2933822066_887646692_2952798786.mp4 | Bin 0 -> 53981 bytes .../498540077_3316948809_223132457.mp4 | Bin 0 -> 91139 bytes .../2933822066_2031271638_2967863776.mp4 | Bin 0 -> 35283 bytes .../2933822066_3179084452_3934815855.mp4 | Bin 0 -> 36549 bytes .../2933822066_4277570474_2501621613.mp4 | Bin 0 -> 38029 bytes .../498540077_2060609914_223132457.mp4 | Bin 0 -> 67775 bytes .../2933822066_1666630342_2001822528.mp4 | Bin 0 -> 30518 bytes .../2933822066_4277570474_1204793078.mp4 | Bin 0 -> 47220 bytes .../2933822066_7843185_1641679825.mp4 | Bin 0 -> 73309 bytes .../498540077_2134406039_223132457.mp4 | Bin 0 -> 117209 bytes .../2933822066_286234973_900026302.mp4 | Bin 0 -> 37484 bytes .../2933822066_4277570474_3976733403.mp4 | Bin 0 -> 42288 bytes .../2933822066_857220353_3777748651.mp4 | Bin 0 -> 42153 bytes .../498540077_1073456939_223132457.mp4 | Bin 0 -> 74383 bytes .../2933822066_1716291363_1647168702.mp4 | Bin 0 -> 56368 bytes .../2933822066_2630333845_4169855342.mp4 | Bin 0 -> 44948 bytes .../2933822066_4277570474_2839993380.mp4 | Bin 0 -> 49570 bytes .../498540077_118822790_223132457.mp4 | Bin 0 -> 97367 bytes .../2933822066_1557795140_374128077.mp4 | Bin 0 -> 35094 bytes .../2933822066_4277570474_3514208436.mp4 | Bin 0 -> 45545 bytes .../2933822066_973063406_3560967737.mp4 | Bin 0 -> 56505 bytes .../498540077_2948670807_223132457.mp4 | Bin 0 -> 98903 bytes .../2933822066_1842399710_1291134229.mp4 | Bin 0 -> 36292 bytes .../2933822066_2270210369_508999682.mp4 | Bin 0 -> 41806 bytes .../2933822066_4277570474_2967280566.mp4 | Bin 0 -> 42750 bytes .../498540077_2122747169_223132457.mp4 | Bin 0 -> 75222 bytes .../outro_generator/1920p30/outro_9x16.mp4 | Bin 0 -> 330419 bytes .../1194513873_1624633994_2233391360.mp4 | Bin 0 -> 87755 bytes .../1194513873_3197887656_697976996.mp4 | Bin 0 -> 50241 bytes .../1194513873_4277570474_1063621336.mp4 | Bin 0 -> 61300 bytes .../1469256112_2213673558_223132457.mp4 | Bin 0 -> 133475 bytes video-creator/assets/outro.mp4 | Bin 0 -> 304818 bytes video-creator/assets/outro_1x1.mp4 | Bin 0 -> 246664 bytes video-creator/assets/outro_21x9.mp4 | Bin 0 -> 194523 bytes video-creator/assets/outro_2x3.mp4 | Bin 0 -> 285135 bytes video-creator/assets/outro_3x2.mp4 | Bin 0 -> 213179 bytes video-creator/assets/outro_3x4.mp4 | Bin 0 -> 519066 bytes video-creator/assets/outro_4x3.mp4 | Bin 0 -> 265149 bytes video-creator/assets/outro_4x5.mp4 | Bin 0 -> 252943 bytes video-creator/assets/outro_5x4.mp4 | Bin 0 -> 212950 bytes video-creator/assets/outro_9x16.mp4 | Bin 0 -> 347671 bytes video-creator/assets/outro_generator.py | 260 ++++++ video-creator/assets/outro_voice.mp3 | Bin 0 -> 13104 bytes video-creator/references/edge_tts_voices.md | 72 ++ video-creator/scripts/scene_splitter.py | 221 +++++ video-creator/scripts/tts_generator.py | 123 +++ video-creator/scripts/video_maker.py | 530 +++++++++++ videocut-clip-oral/README.md | 30 + videocut-clip-oral/SKILL.md | 131 +++ videocut-clip-oral/tips/口误识别方法论.md | 270 ++++++ videocut-clip-oral/tips/转录最佳实践.md | 348 +++++++ videocut-clip/README.md | 23 + videocut-clip/SKILL.md | 154 ++++ videocut-install/README.md | 20 + videocut-install/SKILL.md | 161 ++++ videocut-self-update/README.md | 23 + videocut-self-update/SKILL.md | 118 +++ videocut-subtitle/README.md | 26 + videocut-subtitle/SKILL.md | 113 +++ videocut-subtitle/词典.txt | 8 + 153 files changed, 14581 insertions(+) create mode 100644 README.md create mode 100644 csv-data-summarizer/README.md create mode 100644 csv-data-summarizer/SKILL.md create mode 100644 csv-data-summarizer/analyze.py create mode 100644 csv-data-summarizer/examples/showcase_financial_pl_data.csv create mode 100644 csv-data-summarizer/requirements.txt create mode 100644 csv-data-summarizer/resources/README.md create mode 100644 csv-data-summarizer/resources/sample.csv create mode 100644 deep-research/README.md create mode 100644 deep-research/SKILL.md create mode 100644 deep-research/scripts/format_docx.py create mode 100644 image-service/README.md create mode 100644 image-service/SKILL.md create mode 100644 image-service/config/settings.json create mode 100644 image-service/docs/api-reference.md create mode 100644 image-service/docs/prompt-guide.md create mode 100644 image-service/references/color-sync-guide.md create mode 100644 image-service/references/long-image-guide.md create mode 100644 image-service/references/text-rendering-guide.md create mode 100644 image-service/scripts/image_to_image.py create mode 100644 image-service/scripts/image_to_text.py create mode 100644 image-service/scripts/merge_long_image.py create mode 100644 image-service/scripts/research_image.py create mode 100644 image-service/scripts/text_to_image.py create mode 100644 log-analyzer/README.md create mode 100644 log-analyzer/SKILL.md create mode 100644 log-analyzer/scripts/preprocess.py create mode 100644 mcp-builder/SKILL.md create mode 100644 searchnews/README.md create mode 100644 searchnews/SKILL.md create mode 100644 searchnews/references/keywords.md create mode 100644 searchnews/scripts/ralph/prd.json create mode 100644 searchnews/scripts/ralph/prd.template.json create mode 100644 searchnews/scripts/ralph/progress.txt create mode 100644 searchnews/scripts/ralph/ralph.sh create mode 100644 searchnews/templates/README.md create mode 100644 searchnews/templates/blackboard_chalk.png create mode 100644 skill-creator/SKILL.md create mode 100644 skill-creator/scripts/init_skill.py create mode 100644 skill-creator/scripts/package_skill.py create mode 100644 smart-query/README.md create mode 100644 smart-query/SKILL.md create mode 100644 smart-query/assets/.gitkeep create mode 100644 smart-query/config/settings.json create mode 100644 smart-query/config/settings.json.example create mode 100644 smart-query/references/schema.md create mode 100644 smart-query/scripts/db_connector.py create mode 100644 smart-query/scripts/query.py create mode 100644 smart-query/scripts/schema_loader.py create mode 100644 story-to-scenes/SKILL.md create mode 100644 story-to-scenes/assets/.gitkeep create mode 100644 story-to-scenes/assets/templates/characters_template.md create mode 100644 story-to-scenes/assets/templates/gallery_template.md create mode 100644 story-to-scenes/assets/templates/progress_template.json create mode 100644 story-to-scenes/references/prompt_templates.md create mode 100644 story-to-scenes/references/style_presets.md create mode 100644 uni-agent/README.md create mode 100644 uni-agent/SKILL.md create mode 100644 uni-agent/adapters/__init__.py create mode 100644 uni-agent/adapters/a2a.py create mode 100644 uni-agent/adapters/agent_protocol.py create mode 100644 uni-agent/adapters/aitp.py create mode 100644 uni-agent/adapters/anp.py create mode 100644 uni-agent/adapters/base.py create mode 100644 uni-agent/adapters/lmos.py create mode 100644 uni-agent/adapters/mcp.py create mode 100644 uni-agent/config/agents.yaml create mode 100644 uni-agent/requirements.txt create mode 100644 uni-agent/scripts/test_adapters.py create mode 100644 uni-agent/scripts/test_all.py create mode 100644 uni-agent/scripts/uni_cli.py create mode 100644 uni-agent/setup.sh create mode 100644 uni-agent/test_servers/a2a_server.py create mode 100644 uni-agent/test_servers/agent_protocol_server.py create mode 100644 uni-agent/test_servers/aitp_server.py create mode 100644 uni-agent/test_servers/lmos_server.py create mode 100644 uni-agent/test_servers/mcp_server.py create mode 100644 video-creator/README.md create mode 100644 video-creator/SKILL.md create mode 100644 video-creator/assets/.gitkeep create mode 100644 video-creator/assets/bgm_epic.mp3 create mode 100644 video-creator/assets/bgm_technology.mp3 create mode 100644 video-creator/assets/default_config.yaml create mode 100644 video-creator/assets/example_config.yaml create mode 100644 video-creator/assets/logo.jpg create mode 100644 video-creator/assets/media/texts/25a546dbcb230f7f.svg create mode 100644 video-creator/assets/media/texts/60350aa7fd09283e.svg create mode 100644 video-creator/assets/media/texts/6bc75dd1367e9f40.svg create mode 100644 video-creator/assets/media/texts/94ee77f0d4c0bc26.svg create mode 100644 video-creator/assets/media/texts/9fded5d4bc94afcd.svg create mode 100644 video-creator/assets/media/texts/a47357e721583a39.svg create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation1x1/2933822066_4135568850_2738303351.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation1x1/2933822066_4277570474_1431002730.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation1x1/2933822066_887646692_2952798786.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation1x1/498540077_3316948809_223132457.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation21x9/2933822066_2031271638_2967863776.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation21x9/2933822066_3179084452_3934815855.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation21x9/2933822066_4277570474_2501621613.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation21x9/498540077_2060609914_223132457.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation2x3/2933822066_1666630342_2001822528.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation2x3/2933822066_4277570474_1204793078.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation2x3/2933822066_7843185_1641679825.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation2x3/498540077_2134406039_223132457.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation3x2/2933822066_286234973_900026302.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation3x2/2933822066_4277570474_3976733403.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation3x2/2933822066_857220353_3777748651.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation3x2/498540077_1073456939_223132457.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation4x3/2933822066_1716291363_1647168702.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation4x3/2933822066_2630333845_4169855342.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation4x3/2933822066_4277570474_2839993380.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation4x3/498540077_118822790_223132457.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation4x5/2933822066_1557795140_374128077.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation4x5/2933822066_4277570474_3514208436.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation4x5/2933822066_973063406_3560967737.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation4x5/498540077_2948670807_223132457.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation5x4/2933822066_1842399710_1291134229.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation5x4/2933822066_2270210369_508999682.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation5x4/2933822066_4277570474_2967280566.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1080p30/partial_movie_files/OutroAnimation5x4/498540077_2122747169_223132457.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1920p30/outro_9x16.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1920p30/partial_movie_files/OutroAnimation9x16/1194513873_1624633994_2233391360.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1920p30/partial_movie_files/OutroAnimation9x16/1194513873_3197887656_697976996.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1920p30/partial_movie_files/OutroAnimation9x16/1194513873_4277570474_1063621336.mp4 create mode 100644 video-creator/assets/media/videos/outro_generator/1920p30/partial_movie_files/OutroAnimation9x16/1469256112_2213673558_223132457.mp4 create mode 100644 video-creator/assets/outro.mp4 create mode 100644 video-creator/assets/outro_1x1.mp4 create mode 100644 video-creator/assets/outro_21x9.mp4 create mode 100644 video-creator/assets/outro_2x3.mp4 create mode 100644 video-creator/assets/outro_3x2.mp4 create mode 100644 video-creator/assets/outro_3x4.mp4 create mode 100644 video-creator/assets/outro_4x3.mp4 create mode 100644 video-creator/assets/outro_4x5.mp4 create mode 100644 video-creator/assets/outro_5x4.mp4 create mode 100644 video-creator/assets/outro_9x16.mp4 create mode 100644 video-creator/assets/outro_generator.py create mode 100644 video-creator/assets/outro_voice.mp3 create mode 100644 video-creator/references/edge_tts_voices.md create mode 100644 video-creator/scripts/scene_splitter.py create mode 100644 video-creator/scripts/tts_generator.py create mode 100644 video-creator/scripts/video_maker.py create mode 100644 videocut-clip-oral/README.md create mode 100644 videocut-clip-oral/SKILL.md create mode 100644 videocut-clip-oral/tips/口误识别方法论.md create mode 100644 videocut-clip-oral/tips/转录最佳实践.md create mode 100644 videocut-clip/README.md create mode 100644 videocut-clip/SKILL.md create mode 100644 videocut-install/README.md create mode 100644 videocut-install/SKILL.md create mode 100644 videocut-self-update/README.md create mode 100644 videocut-self-update/SKILL.md create mode 100644 videocut-subtitle/README.md create mode 100644 videocut-subtitle/SKILL.md create mode 100644 videocut-subtitle/词典.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4a22d6 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# OpenCode Skills + +OpenCode 技能集合,扩展 AI Agent 的专业能力。 + +## 技能列表 + +| Skill | 用途 | +|-------|------| +| csv-data-summarizer | CSV 数据分析统计 | +| deep-research | 深度调研报告生成 | +| image-service | 图像生成/编辑/分析 | +| log-analyzer | 日志智能分析 | +| mcp-builder | MCP Server 创建 | +| searchnews | AI 新闻搜索整理 | +| skill-creator | Skill 创建指南和工具 | +| smart-query | 数据库智能查询 | +| story-to-scenes | 故事拆镜生图 | +| uni-agent | 统一 Agent 调度 | +| video-creator | 视频生成 | +| videocut-* | 视频剪辑系列工具 | + +## 使用方式 + +将 skill 目录复制到 `.opencode/skills/` 下即可使用。 + +各 skill 详细使用说明见对应目录的 README.md。 diff --git a/csv-data-summarizer/README.md b/csv-data-summarizer/README.md new file mode 100644 index 0000000..772b01e --- /dev/null +++ b/csv-data-summarizer/README.md @@ -0,0 +1,198 @@ +
+ +[![Join AI Community](https://img.shields.io/badge/🚀_Join-AI_Community_(FREE)-4F46E5?style=for-the-badge)](https://www.skool.com/ai-for-your-business) +[![GitHub Profile](https://img.shields.io/badge/GitHub-@coffeefuelbump-181717?style=for-the-badge&logo=github)](https://github.com/coffeefuelbump) + +[![Link Tree](https://img.shields.io/badge/Linktree-Everything-green?style=for-the-badge&logo=linktree&logoColor=white)](https://linktr.ee/corbin_brown) +[![YouTube Membership](https://img.shields.io/badge/YouTube-Become%20a%20Builder-red?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/channel/UCJFMlSxcvlZg5yZUYJT0Pug/join) + +
+ +--- + +# 📊 CSV Data Summarizer - Claude Skill + +A powerful Claude Skill that automatically analyzes CSV files and generates comprehensive insights with visualizations. Upload any CSV and get instant, intelligent analysis without being asked what you want! + +
+ +[![Version](https://img.shields.io/badge/version-2.1.0-blue.svg)](https://github.com/coffeefuelbump/csv-data-summarizer-claude-skill) +[![Python](https://img.shields.io/badge/python-3.8+-green.svg)](https://www.python.org/) +[![License](https://img.shields.io/badge/license-MIT-orange.svg)](LICENSE) + +
+ +## 🚀 Features + +- **🤖 Intelligent & Adaptive** - Automatically detects data type (sales, customer, financial, survey, etc.) and applies relevant analysis +- **📈 Comprehensive Analysis** - Generates statistics, correlations, distributions, and trends +- **🎨 Auto Visualizations** - Creates multiple charts based on what's in your data: + - Time-series plots for date-based data + - Correlation heatmaps for numeric relationships + - Distribution histograms + - Categorical breakdowns +- **⚡ Proactive** - No questions asked! Just upload CSV and get complete analysis immediately +- **🔍 Data Quality Checks** - Automatically detects and reports missing values +- **📊 Multi-Industry Support** - Adapts to e-commerce, healthcare, finance, operations, surveys, and more + +## 📥 Quick Download + +
+ +### Get Started in 2 Steps + +**1️⃣ Download the Skill** +[![Download Skill](https://img.shields.io/badge/Download-CSV%20Data%20Summarizer%20Skill-blue?style=for-the-badge&logo=download)](https://github.com/coffeefuelbump/csv-data-summarizer-claude-skill/raw/main/csv-data-summarizer.zip) + +**2️⃣ Try the Demo Data** +[![Download Demo CSV](https://img.shields.io/badge/Download-Sample%20P%26L%20Financial%20Data-green?style=for-the-badge&logo=data)](https://github.com/coffeefuelbump/csv-data-summarizer-claude-skill/raw/main/examples/showcase_financial_pl_data.csv) + +
+ +--- + +## 📦 What's Included + +``` +csv-data-summarizer-claude-skill/ +├── SKILL.md # Claude Skill definition +├── analyze.py # Comprehensive analysis engine +├── requirements.txt # Python dependencies +├── examples/ +│ └── showcase_financial_pl_data.csv # Demo P&L financial dataset (15 months, 25 metrics) +└── resources/ + ├── sample.csv # Example dataset + └── README.md # Usage documentation +``` + +## 🎯 How It Works + +1. **Upload** any CSV file to Claude.ai +2. **Skill activates** automatically when CSV is detected +3. **Analysis runs** immediately - inspects data structure and adapts +4. **Results delivered** - Complete analysis with multiple visualizations + +No prompting needed. No options to choose. Just instant, comprehensive insights! + +## 📥 Installation + +### For Claude.ai Users + +1. Download the latest release: [`csv-data-summarizer.zip`](https://github.com/coffeefuelbump/csv-data-summarizer-claude-skill/releases) +2. Go to [Claude.ai](https://claude.ai) → Settings → Capabilities → Skills +3. Upload the zip file +4. Enable the skill +5. Done! Upload any CSV and watch it work ✨ + +### For Developers + +```bash +git clone git@github.com:coffeefuelbump/csv-data-summarizer-claude-skill.git +cd csv-data-summarizer-claude-skill +pip install -r requirements.txt +``` + +## 📊 Sample Dataset Highlights + +The included demo CSV contains **15 months of P&L data** with: +- 3 product lines (SaaS, Enterprise, Services) +- 25 financial metrics including revenue, expenses, margins, CAC, LTV +- Quarterly trends showing business growth +- Perfect for showcasing time-series analysis, correlations, and financial insights + +## 🎨 Example Use Cases + +- **📊 Sales Data** → Revenue trends, product performance, regional analysis +- **👥 Customer Data** → Demographics, segmentation, geographic patterns +- **💰 Financial Data** → Transaction analysis, trend detection, correlations +- **⚙️ Operational Data** → Performance metrics, time-series analysis +- **📋 Survey Data** → Response distributions, cross-tabulations + +## 🛠️ Technical Details + +**Dependencies:** +- Python 3.8+ +- pandas 2.0+ +- matplotlib 3.7+ +- seaborn 0.12+ + +**Visualizations Generated:** +- Time-series trend plots +- Correlation heatmaps +- Distribution histograms +- Categorical bar charts + +## 📝 Example Output + +``` +============================================================ +📊 DATA OVERVIEW +============================================================ +Rows: 100 | Columns: 15 + +📋 DATA TYPES: + • order_date: object + • total_revenue: float64 + • customer_segment: object + ... + +🔍 DATA QUALITY: +✓ No missing values - dataset is complete! + +📈 NUMERICAL ANALYSIS: +[Summary statistics for all numeric columns] + +🔗 CORRELATIONS: +[Correlation matrix showing relationships] + +📅 TIME SERIES ANALYSIS: +Date range: 2024-01-05 to 2024-04-11 +Span: 97 days + +📊 VISUALIZATIONS CREATED: + ✓ correlation_heatmap.png + ✓ time_series_analysis.png + ✓ distributions.png + ✓ categorical_distributions.png +``` + +## 🌟 Connect & Learn More + +
+ +[![Join AI Community](https://img.shields.io/badge/Join-AI%20Community%20(FREE)-blue?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJ3aGl0ZSI+PHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bTAgM2MxLjY2IDAgMyAxLjM0IDMgM3MtMS4zNCAzLTMgMy0zLTEuMzQtMy0zIDEuMzQtMyAzLTN6bTAgMTQuMmMtMi41IDAtNC43MS0xLjI4LTYtMy4yMi4wMy0xLjk5IDQtMy4wOCA2LTMuMDggMS45OSAwIDUuOTcgMS4wOSA2IDMuMDgtMS4yOSAxLjk0LTMuNSAzLjIyLTYgMy4yMnoiLz48L3N2Zz4=)](https://www.skool.com/ai-for-your-business/about) + +[![Link Tree](https://img.shields.io/badge/Linktree-Everything-green?style=for-the-badge&logo=linktree&logoColor=white)](https://linktr.ee/corbin_brown) + +[![YouTube Membership](https://img.shields.io/badge/YouTube-Become%20a%20Builder-red?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/channel/UCJFMlSxcvlZg5yZUYJT0Pug/join) + +[![Twitter Follow](https://img.shields.io/badge/Twitter-Follow%20@corbin__braun-1DA1F2?style=for-the-badge&logo=twitter&logoColor=white)](https://twitter.com/corbin_braun) + +
+ +## 🤝 Contributing + +Contributions are welcome! Feel free to: +- Report bugs +- Suggest new features +- Submit pull requests +- Share your use cases + +## 📄 License + +MIT License - feel free to use this skill for personal or commercial projects! + +## 🙏 Acknowledgments + +Built for the Claude Skills platform by [Anthropic](https://www.anthropic.com/news/skills). + +--- + +
+ +**Made with ❤️ for the AI community** + +⭐ Star this repo if you find it useful! + +
+ diff --git a/csv-data-summarizer/SKILL.md b/csv-data-summarizer/SKILL.md new file mode 100644 index 0000000..c55e7f8 --- /dev/null +++ b/csv-data-summarizer/SKILL.md @@ -0,0 +1,148 @@ +--- +name: csv-data-summarizer +description: CSV数据分析技能。使用Python和pandas分析CSV文件,生成统计摘要和快速可视化图表。当用户上传或提到CSV文件、需要分析表格数据时自动使用。 +metadata: + version: "2.1.0" + dependencies: python>=3.8, pandas>=2.0.0, matplotlib>=3.7.0, seaborn>=0.12.0 +--- + +# CSV 数据分析器 + +此技能分析 CSV 文件并提供包含统计洞察和可视化的全面摘要。 + +## 何时使用此技能 + +当用户: +- 上传或提到 CSV 文件 +- 要求汇总、分析或可视化表格数据 +- 请求从 CSV 数据中获取洞察 +- 想了解数据结构和质量 + +## 工作原理 + +## ⚠️ 关键行为要求 ⚠️ + +**不要问用户想用数据做什么。** +**不要提供选项或选择。** +**不要说"您想让我帮您做什么?"** +**不要列出可能的分析选项。** + +**立即自动执行:** +1. 运行全面分析 +2. 生成所有相关可视化 +3. 展示完整结果 +4. 不提问、不给选项、不等待用户输入 + +**用户想要立即获得完整分析 - 直接做就行。** + +### 自动分析步骤: + +**该技能通过先检查数据,然后确定最相关的分析,智能适应不同的数据类型和行业。** + +1. **加载并检查** CSV 文件到 pandas DataFrame +2. **识别数据结构** - 列类型、日期列、数值列、类别 +3. **根据数据内容确定相关分析**: + - **销售/电商数据**(订单日期、收入、产品):时间序列趋势、收入分析、产品表现 + - **客户数据**(人口统计、细分、区域):分布分析、细分、地理模式 + - **财务数据**(交易、金额、日期):趋势分析、统计摘要、相关性 + - **运营数据**(时间戳、指标、状态):时间序列、绩效指标、分布 + - **调查数据**(分类响应、评分):频率分析、交叉表、分布 + - **通用表格数据**:根据找到的列类型调整 + +4. **只创建对特定数据集有意义的可视化**: + - 时间序列图仅在存在日期/时间戳列时 + - 相关性热图仅在存在多个数值列时 + - 类别分布仅在存在分类列时 + - 数值分布的直方图(相关时) + +5. **自动生成全面输出**包括: + - 数据概览(行数、列数、类型) + - 与数据类型相关的关键统计和指标 + - 缺失数据分析 + - 多个相关可视化(仅适用的那些) + - 基于此特定数据集中发现的模式的可操作洞察 + +6. **一次性展示所有内容** - 不追问 + +**适应示例:** +- 带患者ID的医疗数据 → 专注于人口统计、治疗模式、时间趋势 +- 带库存水平的库存数据 → 专注于数量分布、补货模式、SKU分析 +- 带时间戳的网站分析 → 专注于流量模式、转化指标、时段分析 +- 调查响应 → 专注于响应分布、人口统计细分、情感模式 + +### 行为指南 + +✅ **正确方法 - 这样说:** +- "我现在对这些数据进行全面分析。" +- "这是带可视化的完整分析:" +- "我识别出这是[类型]数据并生成了相关洞察:" +- 然后立即展示完整分析 + +✅ **要做:** +- 立即运行分析脚本 +- 自动生成所有相关图表 +- 无需询问即提供完整洞察 +- 在第一次响应中就做到全面完整 +- 果断行动,不需征求许可 + +❌ **永远不要说这些话:** +- "您想用这些数据做什么?" +- "您想让我帮您做什么?" +- "这里有一些常见选项:" +- "让我知道您想要什么帮助" +- "如果您愿意,我可以创建全面分析!" +- 任何以"?"结尾询问用户方向的句子 +- 任何选项或选择列表 +- 任何条件性的"如果您想,我可以做X" + +❌ **禁止行为:** +- 询问用户想要什么 +- 列出选项供用户选择 +- 在分析前等待用户指示 +- 提供需要后续跟进的部分分析 +- 描述你可以做什么而不是直接做 + +### 使用方法 + +该技能提供 Python 函数 `summarize_csv(file_path)`: +- 接受 CSV 文件的路径 +- 返回带统计信息的全面文本摘要 +- 根据数据结构自动生成多个可视化 + +### 示例提示 + +> "这是 `sales_data.csv`。你能汇总这个文件吗?" + +> "分析这个客户数据 CSV 并展示趋势。" + +> "你能从 `orders.csv` 中发现什么洞察?" + +### 示例输出 + +**数据集概览** +- 5,000 行 × 8 列 +- 3 个数值列,1 个日期列 + +**统计摘要** +- 平均订单价值:$58.2 +- 标准差:$12.4 +- 缺失值:2%(100个单元格) + +**洞察** +- 销售随时间呈上升趋势 +- Q4活动达到峰值 +*(附:趋势图)* + +## 文件 + +- `analyze.py` - 核心分析逻辑 +- `requirements.txt` - Python 依赖 +- `resources/sample.csv` - 用于测试的示例数据集 +- `resources/README.md` - 附加文档 + +## 注意事项 + +- 自动检测日期列(名称中包含 'date' 的列) +- 优雅处理缺失数据 +- 仅在存在日期列时生成可视化 +- 所有数值列都包含在统计摘要中 diff --git a/csv-data-summarizer/analyze.py b/csv-data-summarizer/analyze.py new file mode 100644 index 0000000..5931524 --- /dev/null +++ b/csv-data-summarizer/analyze.py @@ -0,0 +1,182 @@ +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns +from pathlib import Path + +def summarize_csv(file_path): + """ + Comprehensively analyzes a CSV file and generates multiple visualizations. + + Args: + file_path (str): Path to the CSV file + + Returns: + str: Formatted comprehensive analysis of the dataset + """ + df = pd.read_csv(file_path) + summary = [] + charts_created = [] + + # Basic info + summary.append("=" * 60) + summary.append("📊 DATA OVERVIEW") + summary.append("=" * 60) + summary.append(f"Rows: {df.shape[0]:,} | Columns: {df.shape[1]}") + summary.append(f"\nColumns: {', '.join(df.columns.tolist())}") + + # Data types + summary.append(f"\n📋 DATA TYPES:") + for col, dtype in df.dtypes.items(): + summary.append(f" • {col}: {dtype}") + + # Missing data analysis + missing = df.isnull().sum().sum() + missing_pct = (missing / (df.shape[0] * df.shape[1])) * 100 + summary.append(f"\n🔍 DATA QUALITY:") + if missing: + summary.append(f"Missing values: {missing:,} ({missing_pct:.2f}% of total data)") + summary.append("Missing by column:") + for col in df.columns: + col_missing = df[col].isnull().sum() + if col_missing > 0: + col_pct = (col_missing / len(df)) * 100 + summary.append(f" • {col}: {col_missing:,} ({col_pct:.1f}%)") + else: + summary.append("✓ No missing values - dataset is complete!") + + # Numeric analysis + numeric_cols = df.select_dtypes(include='number').columns.tolist() + if numeric_cols: + summary.append(f"\n📈 NUMERICAL ANALYSIS:") + summary.append(str(df[numeric_cols].describe())) + + # Correlations if multiple numeric columns + if len(numeric_cols) > 1: + summary.append(f"\n🔗 CORRELATIONS:") + corr_matrix = df[numeric_cols].corr() + summary.append(str(corr_matrix)) + + # Create correlation heatmap + plt.figure(figsize=(10, 8)) + sns.heatmap(corr_matrix, annot=True, cmap='coolwarm', center=0, + square=True, linewidths=1) + plt.title('Correlation Heatmap') + plt.tight_layout() + plt.savefig('correlation_heatmap.png', dpi=150) + plt.close() + charts_created.append('correlation_heatmap.png') + + # Categorical analysis + categorical_cols = df.select_dtypes(include=['object']).columns.tolist() + categorical_cols = [c for c in categorical_cols if 'id' not in c.lower()] + + if categorical_cols: + summary.append(f"\n📊 CATEGORICAL ANALYSIS:") + for col in categorical_cols[:5]: # Limit to first 5 + value_counts = df[col].value_counts() + summary.append(f"\n{col}:") + for val, count in value_counts.head(10).items(): + pct = (count / len(df)) * 100 + summary.append(f" • {val}: {count:,} ({pct:.1f}%)") + + # Time series analysis + date_cols = [c for c in df.columns if 'date' in c.lower() or 'time' in c.lower()] + if date_cols: + summary.append(f"\n📅 TIME SERIES ANALYSIS:") + date_col = date_cols[0] + df[date_col] = pd.to_datetime(df[date_col], errors='coerce') + + date_range = df[date_col].max() - df[date_col].min() + summary.append(f"Date range: {df[date_col].min()} to {df[date_col].max()}") + summary.append(f"Span: {date_range.days} days") + + # Create time-series plots for numeric columns + if numeric_cols: + fig, axes = plt.subplots(min(3, len(numeric_cols)), 1, + figsize=(12, 4 * min(3, len(numeric_cols)))) + if len(numeric_cols) == 1: + axes = [axes] + + for idx, num_col in enumerate(numeric_cols[:3]): + ax = axes[idx] if len(numeric_cols) > 1 else axes[0] + daily_data = df.groupby(date_col)[num_col].agg(['mean', 'sum', 'count']) + daily_data['mean'].plot(ax=ax, label='Average', linewidth=2) + ax.set_title(f'{num_col} Over Time') + ax.set_xlabel('Date') + ax.set_ylabel(num_col) + ax.legend() + ax.grid(True, alpha=0.3) + + plt.tight_layout() + plt.savefig('time_series_analysis.png', dpi=150) + plt.close() + charts_created.append('time_series_analysis.png') + + # Distribution plots for numeric columns + if numeric_cols: + n_cols = min(4, len(numeric_cols)) + fig, axes = plt.subplots(2, 2, figsize=(12, 10)) + axes = axes.flatten() + + for idx, col in enumerate(numeric_cols[:4]): + axes[idx].hist(df[col].dropna(), bins=30, edgecolor='black', alpha=0.7) + axes[idx].set_title(f'Distribution of {col}') + axes[idx].set_xlabel(col) + axes[idx].set_ylabel('Frequency') + axes[idx].grid(True, alpha=0.3) + + # Hide unused subplots + for idx in range(len(numeric_cols[:4]), 4): + axes[idx].set_visible(False) + + plt.tight_layout() + plt.savefig('distributions.png', dpi=150) + plt.close() + charts_created.append('distributions.png') + + # Categorical distributions + if categorical_cols: + fig, axes = plt.subplots(2, 2, figsize=(14, 10)) + axes = axes.flatten() + + for idx, col in enumerate(categorical_cols[:4]): + value_counts = df[col].value_counts().head(10) + axes[idx].barh(range(len(value_counts)), value_counts.values) + axes[idx].set_yticks(range(len(value_counts))) + axes[idx].set_yticklabels(value_counts.index) + axes[idx].set_title(f'Top Values in {col}') + axes[idx].set_xlabel('Count') + axes[idx].grid(True, alpha=0.3, axis='x') + + # Hide unused subplots + for idx in range(len(categorical_cols[:4]), 4): + axes[idx].set_visible(False) + + plt.tight_layout() + plt.savefig('categorical_distributions.png', dpi=150) + plt.close() + charts_created.append('categorical_distributions.png') + + # Summary of visualizations + if charts_created: + summary.append(f"\n📊 VISUALIZATIONS CREATED:") + for chart in charts_created: + summary.append(f" ✓ {chart}") + + summary.append("\n" + "=" * 60) + summary.append("✅ COMPREHENSIVE ANALYSIS COMPLETE") + summary.append("=" * 60) + + return "\n".join(summary) + + +if __name__ == "__main__": + # Test with sample data + import sys + if len(sys.argv) > 1: + file_path = sys.argv[1] + else: + file_path = "resources/sample.csv" + + print(summarize_csv(file_path)) + diff --git a/csv-data-summarizer/examples/showcase_financial_pl_data.csv b/csv-data-summarizer/examples/showcase_financial_pl_data.csv new file mode 100644 index 0000000..395b0e5 --- /dev/null +++ b/csv-data-summarizer/examples/showcase_financial_pl_data.csv @@ -0,0 +1,46 @@ +month,year,quarter,product_line,total_revenue,cost_of_goods_sold,gross_profit,gross_margin_pct,marketing_expense,sales_expense,rd_expense,admin_expense,total_operating_expenses,operating_income,operating_margin_pct,interest_expense,tax_expense,net_income,net_margin_pct,customer_acquisition_cost,customer_lifetime_value,units_sold,avg_selling_price,headcount,revenue_per_employee +Jan,2023,Q1,SaaS Platform,450000,135000,315000,70.0,65000,85000,45000,35000,230000,85000,18.9,5000,16000,64000,14.2,125,2400,1200,375,45,10000 +Jan,2023,Q1,Enterprise Solutions,280000,112000,168000,60.0,35000,55000,25000,20000,135000,33000,11.8,3000,6600,23400,8.4,450,8500,450,622,45,6222 +Jan,2023,Q1,Professional Services,125000,50000,75000,60.0,15000,22000,8000,12000,57000,18000,14.4,1500,3600,12900,10.3,200,3200,95,1316,45,2778 +Feb,2023,Q1,SaaS Platform,475000,142500,332500,70.0,68000,89000,47000,36000,240000,92500,19.5,5200,18500,68800,14.5,120,2500,1300,365,47,10106 +Feb,2023,Q1,Enterprise Solutions,295000,118000,177000,60.0,38000,58000,27000,22000,145000,32000,10.8,3200,6400,22400,7.6,440,8600,470,628,47,6277 +Feb,2023,Q1,Professional Services,135000,54000,81000,60.0,16000,24000,9000,13000,62000,19000,14.1,1600,3800,13600,10.1,195,3300,105,1286,47,2872 +Mar,2023,Q1,SaaS Platform,520000,156000,364000,70.0,75000,95000,52000,40000,262000,102000,19.6,5500,19250,77250,14.9,115,2650,1450,359,50,10400 +Mar,2023,Q1,Enterprise Solutions,325000,130000,195000,60.0,42000,63000,30000,25000,160000,35000,10.8,3500,7000,24500,7.5,425,8800,520,625,50,6500 +Mar,2023,Q1,Professional Services,148000,59200,88800,60.0,18000,26000,10000,14000,68000,20800,14.1,1800,4160,14840,10.0,190,3400,115,1287,50,2960 +Apr,2023,Q2,SaaS Platform,555000,166500,388500,70.0,80000,100000,55000,42000,277000,111500,20.1,5800,22300,83400,15.0,110,2750,1550,358,52,10673 +Apr,2023,Q2,Enterprise Solutions,340000,136000,204000,60.0,45000,65000,32000,26000,168000,36000,10.6,3700,7200,25100,7.4,420,9000,540,630,52,6538 +Apr,2023,Q2,Professional Services,158000,63200,94800,60.0,19000,27000,11000,15000,72000,22800,14.4,1900,4560,16340,10.3,185,3500,125,1264,52,3038 +May,2023,Q2,SaaS Platform,590000,177000,413000,70.0,85000,105000,58000,44000,292000,121000,20.5,6000,24200,90800,15.4,105,2850,1650,358,55,10727 +May,2023,Q2,Enterprise Solutions,365000,146000,219000,60.0,48000,68000,35000,28000,179000,40000,11.0,4000,8000,28000,7.7,410,9200,580,629,55,6636 +May,2023,Q2,Professional Services,172000,68800,103200,60.0,21000,29000,12000,16000,78000,25200,14.7,2100,5040,18060,10.5,180,3600,135,1274,55,3127 +Jun,2023,Q2,SaaS Platform,625000,187500,437500,70.0,90000,110000,62000,46000,308000,129500,20.7,6200,25850,97450,15.6,100,2950,1750,357,58,10776 +Jun,2023,Q2,Enterprise Solutions,385000,154000,231000,60.0,50000,70000,37000,29000,186000,45000,11.7,4200,9000,31800,8.3,400,9400,610,631,58,6638 +Jun,2023,Q2,Professional Services,185000,74000,111000,60.0,22000,31000,13000,17000,83000,28000,15.1,2200,5580,20220,10.9,175,3700,145,1276,58,3190 +Jul,2023,Q3,SaaS Platform,665000,199500,465500,70.0,95000,115000,65000,48000,323000,142500,21.4,6500,28500,107500,16.2,95,3050,1850,359,60,11083 +Jul,2023,Q3,Enterprise Solutions,410000,164000,246000,60.0,53000,73000,40000,31000,197000,49000,12.0,4400,9800,34800,8.5,390,9600,650,631,60,6833 +Jul,2023,Q3,Professional Services,198000,79200,118800,60.0,24000,33000,14000,18000,89000,29800,15.1,2400,5960,21440,10.8,170,3800,155,1277,60,3300 +Aug,2023,Q3,SaaS Platform,705000,211500,493500,70.0,100000,120000,68000,50000,338000,155500,22.1,6800,31100,117600,16.7,90,3150,1950,362,63,11190 +Aug,2023,Q3,Enterprise Solutions,435000,174000,261000,60.0,56000,76000,42000,33000,207000,54000,12.4,4600,10800,38600,8.9,380,9800,690,630,63,6905 +Aug,2023,Q3,Professional Services,210000,84000,126000,60.0,25000,35000,15000,19000,94000,32000,15.2,2500,6400,23100,11.0,165,3900,165,1273,63,3333 +Sep,2023,Q3,SaaS Platform,750000,225000,525000,70.0,108000,128000,72000,53000,361000,164000,21.9,7200,33360,123440,16.5,88,3250,2080,360,65,11538 +Sep,2023,Q3,Enterprise Solutions,465000,186000,279000,60.0,60000,80000,45000,35000,220000,59000,12.7,5000,11800,42200,9.1,370,10000,735,633,65,7154 +Sep,2023,Q3,Professional Services,225000,90000,135000,60.0,27000,37000,16000,20000,100000,35000,15.6,2700,6920,25380,11.3,160,4000,175,1286,65,3462 +Oct,2023,Q4,SaaS Platform,795000,238500,556500,70.0,115000,135000,75000,55000,380000,176500,22.2,7500,35870,133130,16.7,85,3350,2200,361,68,11691 +Oct,2023,Q4,Enterprise Solutions,490000,196000,294000,60.0,63000,83000,47000,36000,229000,65000,13.3,5200,13000,46800,9.6,360,10200,770,636,68,7206 +Oct,2023,Q4,Professional Services,238000,95200,142800,60.0,29000,39000,17000,21000,106000,36800,15.5,2800,7360,26640,11.2,158,4100,185,1286,68,3500 +Nov,2023,Q4,SaaS Platform,840000,252000,588000,70.0,122000,142000,78000,58000,400000,188000,22.4,7800,38440,141760,16.9,82,3450,2320,362,70,12000 +Nov,2023,Q4,Enterprise Solutions,520000,208000,312000,60.0,67000,87000,50000,38000,242000,70000,13.5,5500,14100,50400,9.7,355,10400,815,638,70,7429 +Nov,2023,Q4,Professional Services,252000,100800,151200,60.0,31000,41000,18000,22000,112000,39200,15.6,3000,7728,28472,11.3,155,4200,195,1292,70,3600 +Dec,2023,Q4,SaaS Platform,895000,268500,626500,70.0,130000,150000,82000,62000,424000,202500,22.6,8200,41145,153155,17.1,80,3550,2480,361,72,12431 +Dec,2023,Q4,Enterprise Solutions,555000,222000,333000,60.0,72000,92000,53000,40000,257000,76000,13.7,6000,15400,54600,9.8,350,10600,870,638,72,7708 +Dec,2023,Q4,Professional Services,268000,107200,160800,60.0,33000,43000,19000,23000,118000,42800,16.0,3200,8352,31248,11.7,152,4300,205,1307,72,3722 +Jan,2024,Q1,SaaS Platform,925000,277500,647500,70.0,135000,155000,85000,64000,439000,208500,22.5,8500,42070,157930,17.1,78,3650,2550,363,75,12333 +Jan,2024,Q1,Enterprise Solutions,575000,230000,345000,60.0,75000,95000,55000,42000,267000,78000,13.6,6200,15760,56040,9.7,345,10800,900,639,75,7667 +Jan,2024,Q1,Professional Services,280000,112000,168000,60.0,34000,45000,20000,24000,123000,45000,16.1,3300,8770,32930,11.8,150,4400,215,1302,75,3733 +Feb,2024,Q1,SaaS Platform,965000,289500,675500,70.0,140000,160000,88000,66000,454000,221500,23.0,8800,44510,168190,17.4,75,3750,2660,363,77,12532 +Feb,2024,Q1,Enterprise Solutions,600000,240000,360000,60.0,78000,98000,57000,43000,276000,84000,14.0,6400,16800,60800,10.1,340,11000,940,638,77,7792 +Feb,2024,Q1,Professional Services,295000,118000,177000,60.0,36000,47000,21000,25000,129000,48000,16.3,3500,9420,35080,11.9,148,4500,225,1311,77,3831 +Mar,2024,Q1,SaaS Platform,1020000,306000,714000,70.0,148000,168000,92000,69000,477000,237000,23.2,9200,47880,179920,17.6,73,3850,2810,363,80,12750 +Mar,2024,Q1,Enterprise Solutions,635000,254000,381000,60.0,82000,103000,60000,45000,290000,91000,14.3,6800,18200,66000,10.4,335,11200,990,641,80,7938 +Mar,2024,Q1,Professional Services,312000,124800,187200,60.0,38000,49000,22000,26000,135000,52200,16.7,3700,10230,38270,12.3,145,4600,240,1300,80,3900 diff --git a/csv-data-summarizer/requirements.txt b/csv-data-summarizer/requirements.txt new file mode 100644 index 0000000..24647d8 --- /dev/null +++ b/csv-data-summarizer/requirements.txt @@ -0,0 +1,4 @@ +pandas>=2.0.0 +matplotlib>=3.7.0 +seaborn>=0.12.0 + diff --git a/csv-data-summarizer/resources/README.md b/csv-data-summarizer/resources/README.md new file mode 100644 index 0000000..6e9c613 --- /dev/null +++ b/csv-data-summarizer/resources/README.md @@ -0,0 +1,83 @@ +# CSV Data Summarizer - Resources + +--- + +## 🌟 Connect & Learn More + +
+ +### 🚀 **Join Our Community** +[![Join AI Community](https://img.shields.io/badge/Join-AI%20Community%20(FREE)-blue?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJ3aGl0ZSI+PHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bTAgM2MxLjY2IDAgMyAxLjM0IDMgM3MtMS4zNCAzLTMgMy0zLTEuMzQtMy0zIDEuMzQtMyAzLTN6bTAgMTQuMmMtMi41IDAtNC43MS0xLjI4LTYtMy4yMi4wMy0xLjk5IDQtMy4wOCA2LTMuMDggMS45OSAwIDUuOTcgMS4wOSA2IDMuMDgtMS4yOSAxLjk0LTMuNSAzLjIyLTYgMy4yMnoiLz48L3N2Zz4=)](https://www.skool.com/ai-for-your-business/about) + +### 🔗 **All My Links** +[![Link Tree](https://img.shields.io/badge/Linktree-Everything-green?style=for-the-badge&logo=linktree&logoColor=white)](https://linktr.ee/corbin_brown) + +### 🛠️ **Become a Builder** +[![YouTube Membership](https://img.shields.io/badge/YouTube-Become%20a%20Builder-red?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/channel/UCJFMlSxcvlZg5yZUYJT0Pug/join) + +### 🐦 **Follow on Twitter** +[![Twitter Follow](https://img.shields.io/badge/Twitter-Follow%20@corbin__braun-1DA1F2?style=for-the-badge&logo=twitter&logoColor=white)](https://twitter.com/corbin_braun) + +
+ +--- + +## Sample Data + +The `sample.csv` file contains example sales data with the following columns: + +- **date**: Transaction date +- **product**: Product name (Widget A, B, or C) +- **quantity**: Number of items sold +- **revenue**: Total revenue from the transaction +- **customer_id**: Unique customer identifier +- **region**: Geographic region (North, South, East, West) + +## Usage Examples + +### Basic Summary +``` +Analyze sample.csv +``` + +### With Custom CSV +``` +Here's my sales_data.csv file. Can you summarize it? +``` + +### Focus on Specific Insights +``` +What are the revenue trends in this dataset? +``` + +## Testing the Skill + +You can test the skill locally before uploading to Claude: + +```bash +# Install dependencies +pip install -r ../requirements.txt + +# Run the analysis +python ../analyze.py sample.csv +``` + +## Expected Output + +The analysis will provide: + +1. **Dataset dimensions** - Row and column counts +2. **Column information** - Names and data types +3. **Summary statistics** - Mean, median, std dev, min/max for numeric columns +4. **Data quality** - Missing value detection and counts +5. **Visualizations** - Time-series plots when date columns are present + +## Customization + +To adapt this skill for your specific use case: + +1. Modify `analyze.py` to include domain-specific calculations +2. Add custom visualization types in the plotting section +3. Include validation rules specific to your data +4. Add more sample datasets to test different scenarios + diff --git a/csv-data-summarizer/resources/sample.csv b/csv-data-summarizer/resources/sample.csv new file mode 100644 index 0000000..348a814 --- /dev/null +++ b/csv-data-summarizer/resources/sample.csv @@ -0,0 +1,22 @@ +date,product,quantity,revenue,customer_id,region +2024-01-15,Widget A,5,129.99,C001,North +2024-01-16,Widget B,3,89.97,C002,South +2024-01-17,Widget A,7,181.98,C003,East +2024-01-18,Widget C,2,199.98,C001,North +2024-01-19,Widget B,4,119.96,C004,West +2024-01-20,Widget A,6,155.94,C005,South +2024-01-21,Widget C,1,99.99,C002,South +2024-01-22,Widget B,8,239.92,C006,East +2024-01-23,Widget A,3,77.97,C007,North +2024-01-24,Widget C,5,499.95,C003,East +2024-01-25,Widget B,2,59.98,C008,West +2024-01-26,Widget A,9,233.91,C004,West +2024-01-27,Widget C,3,299.97,C009,North +2024-01-28,Widget B,6,179.94,C010,South +2024-01-29,Widget A,4,103.96,C005,South +2024-01-30,Widget C,7,699.93,C011,East +2024-01-31,Widget B,5,149.95,C012,West +2024-02-01,Widget A,8,207.92,C013,North +2024-02-02,Widget C,2,199.98,C014,South +2024-02-03,Widget B,10,299.90,C015,East + diff --git a/deep-research/README.md b/deep-research/README.md new file mode 100644 index 0000000..0dad1c3 --- /dev/null +++ b/deep-research/README.md @@ -0,0 +1,18 @@ +# Deep Research + +深度调研技能,对技术主题进行调研,输出 Markdown + Word 报告。 + +## 依赖 + +```bash +# 必需 +brew install pandoc +pip install python-docx + +# 可选(生成配图) +# 需要 image-service skill 并配置 API Key +``` + +## 使用 + +加载 skill 后,直接告诉 Agent 要调研的主题即可。 diff --git a/deep-research/SKILL.md b/deep-research/SKILL.md new file mode 100644 index 0000000..69b3578 --- /dev/null +++ b/deep-research/SKILL.md @@ -0,0 +1,346 @@ +--- +name: deep-research +description: 当用户要求"调研"、"深度调研"、"帮我研究"、"调研下这个",或提到需要搜索、整理、汇总指定主题的技术内容时,应使用此技能。 +metadata: + version: "1.0.0" +--- + +# 深度调研技能(Deep Research Skill) + +## 技能概述 + +此技能用于对技术主题进行深度调研,输出专业的调研报告文档。 + +| 能力 | 说明 | +|-----|------| +| 内容提取 | 从 URL、文档中提取核心信息 | +| 深度调研 | 联网搜索补充背景、对比、最新进展 | +| 报告生成 | **默认生成 Markdown 和 Word 两个版本** | +| 图解生成 | 为核心概念生成技术信息图 | +| Word 格式化 | 自动处理目录、标题加粗、表格实线等样式 | + +## 触发规则 + +当用户消息包含以下关键词时使用此技能: +- 调研、深度调研、调研报告 +- 帮我研究、帮我分析 +- 调研下这个、看看这个 + +## 输出规范 + +每次调研任务必须同时提供: +1. **Markdown 版本**:用于 Obsidian 知识库沉淀和双链关联 +2. **Word 版本**:用于正式汇报和外部分享,需经过脚本格式化处理 + +## 目录结构 + +每个调研主题创建独立文件夹,保持整洁: + +``` +{output_dir}/ +├── Ralph-Loop/ # 主题文件夹(英文短横线命名) +│ ├── images/ # 该主题的信息图 +│ │ ├── architecture.png +│ │ └── comparison.png +│ ├── Ralph-Loop调研报告.md # Markdown 报告 +│ └── Ralph-Loop调研报告.docx # Word 报告 +├── MCP-Protocol/ +│ ├── images/ +│ ├── MCP-Protocol调研报告.md +│ └── MCP-Protocol调研报告.docx +└── ... +``` + +命名规范: +- 文件夹名:英文,单词间用短横线连接,如 `Ralph-Loop`、`MCP-Protocol` +- 报告文件:`{主题名}调研报告.md` 和 `{主题名}调研报告.docx` +- 图片目录:每个主题文件夹下单独的 `images/` 目录 + +## 调研流程 + +### 第一步:创建主题目录 + +根据调研主题创建独立文件夹: + +```bash +mkdir -p "{output_dir}/{主题名}/images" +``` + +### 第二步:内容获取 + +1. 如果用户提供 URL,使用 webfetch 获取内容 +2. 提炼核心概念、技术原理、关键信息 +3. 识别需要深入调研的点 + +### 第三步:深度调研 + +使用 Task 工具进行联网搜索,补充: +- 技术背景和发展历程 +- 竞品对比和差异化 +- 社区讨论和实际案例 +- GitHub 仓库和开源实现 +- 最新进展和趋势 + +### 第四步:图解生成 + +使用预设风格脚本生成统一手绘风格的信息图。 + +#### 生图触发规则 + +| 内容类型 | 是否生图 | 图解类型 | 说明 | +|---------|---------|---------|------| +| 核心架构/原理 | 必须 | arch | 系统结构、技术栈、模块组成 | +| 流程/步骤 | 必须 | flow | 工作流、执行顺序、操作步骤 | +| A vs B 对比 | 必须 | compare | 两种方案/技术的对比 | +| 3个以上要素 | 建议 | concept | 核心概念、多个方面组成 | +| 纯文字表格 | 不需要 | - | 用 Markdown 表格即可 | +| 代码示例 | 不需要 | - | 用代码块即可 | + +#### 预设风格模板 + +所有配图统一使用手绘体可视化风格,保持系列一致性: + +| 类型 | 命令参数 | 配色 | 布局 | +|------|---------|------|------| +| 架构图 | `-t arch` | 科技蓝 #4A90D9 | 分层/模块化 | +| 流程图 | `-t flow` | 蓝+绿+橙 | 从上到下 | +| 对比图 | `-t compare` | 蓝 vs 橙 | 左右分栏 | +| 概念图 | `-t concept` | 蓝紫渐变 | 中心发散 | + +#### 生成命令 + +使用 `research_image.py` 脚本生成: + +```bash +# 架构图 +python .opencode/skills/image-service/scripts/research_image.py \ + -t arch \ + -n "Ralph Loop 核心架构" \ + -c "展示 Prompt、Agent、Stop Hook、Files 四个模块的循环关系" \ + -o "{output_dir}/{主题名}/images/architecture.png" + +# 流程图 +python .opencode/skills/image-service/scripts/research_image.py \ + -t flow \ + -n "Stop Hook 工作流程" \ + -c "Agent尝试退出、Hook触发、检查条件、允许或阻止退出的完整流程" \ + -o "{output_dir}/{主题名}/images/flow.png" + +# 对比图 +python .opencode/skills/image-service/scripts/research_image.py \ + -t compare \ + -n "ReAct vs Ralph Loop" \ + -c "左侧ReAct依赖自我评估停止,右侧Ralph使用外部Hook控制" \ + -o "{output_dir}/{主题名}/images/comparison.png" + +# 概念图 +python .opencode/skills/image-service/scripts/research_image.py \ + -t concept \ + -n "状态持久化要素" \ + -c "中心是Agent,周围是progress.txt、prd.json、Git历史、代码文件" \ + -o "{output_dir}/{主题名}/images/concept.png" +``` + +#### 图片命名规范 + +| 图解类型 | 文件名 | +|---------|--------| +| 架构图 | `architecture.png` 或 `{具体名称}_arch.png` | +| 流程图 | `flow.png` 或 `{具体名称}_flow.png` | +| 对比图 | `comparison.png` 或 `{A}_vs_{B}.png` | +| 概念图 | `concept.png` 或 `{具体名称}_concept.png` | + +### 第五步:报告撰写 + +按标准模板撰写 Markdown 报告,存放到主题文件夹: + +``` +{output_dir}/{主题名}/{主题名}调研报告.md +``` + +报告中引用图片使用相对路径: +```markdown +![架构图](images/architecture.png) +``` + +### 第六步:Word 导出 + +```bash +# 进入主题目录 +cd "{output_dir}/{主题名}" + +# 生成 Word(--resource-path=. 确保图片正确引用) +# 注意:不要使用 --toc 参数,因为 Markdown 中已有手写目录 +pandoc "{主题名}调研报告.md" -o "{主题名}调研报告.docx" --resource-path=. + +# 格式化 Word +python ../../../.opencode/skills/deep-research/scripts/format_docx.py "{主题名}调研报告.docx" +``` + +## 写作原则 + +调研报告的核心价值:深入研究、降低团队吸收成本、提供专家级建议。 + +1. 理解透彻:不能一知半解或大段拷贝,必须消化吸收后用自己的话表达 +2. 体现思考:有判断、有建议,而非仅仅陈述现状 +3. 细节佐证:有过程和细节支撑结论,不空谈 +4. 逻辑清晰:有分段、有结构、有编号 +5. 配图说明:核心概念必须配信息图 +6. 去除 AI 味: + - 不使用「」、" " 等特殊符号 + - 不用过多强调符号和 emoji + - 行文自然流畅,像人写的专业文档 + - 避免"首先、其次、总之"等套话 + +## 报告模板 + +```markdown +--- +date: YYYY-MM-DD +type: 调研报告 +领域: {技术领域} +tags: [调研, {主题关键词}] +--- + +# XX调研报告 + +> 调研日期:YYYY年M月D日 + +--- + +## 目录 + +- 一、简介 +- 二、启示 +- 三、核心介绍 + - 3.1 XXX + - 3.2 XXX +- 四、附录 + - 4.1 详细文档 + - 4.2 参考资料 + +--- + +## 一、简介 + +(快速说明调研内容,简短重点) + +是什么,主要用来做什么,属于什么类别。有哪些能力,有什么特点。和竞品相比,有哪些区别,主打什么。 + +1. 要点一 +2. 要点二 +3. 要点三 + +--- + +## 二、启示 + +(调研内容带来的启示、值得学习借鉴之处、与现有产品如何结合、是否值得推荐) + +1. 启示一 +2. 启示二 +3. 启示三 + +--- + +## 三、核心介绍 + +(正文部分,详细说明调研内容的原理/搭建/操作/使用过程,含信息图及流程说明) + +### 3.1 XXX + +![图解说明](images/xxx.png) + +上图展示了...(图解说明,让读者看图就能理解) + +详细内容... + +### 3.2 XXX + +详细内容... + +--- + +## 四、附录 + +### 4.1 详细文档 + +(更详细的配置/操作过程) + +### 4.2 参考资料 + +**官方文档** + +- 文档名称: https://xxx + +**开源实现** + +- 项目名称: https://github.com/xxx + +**社区讨论** + +- 讨论来源: https://xxx +``` + +## 脚本说明 + +### format_docx.py + +Word 文档格式化脚本,功能包括: + +1. 标题居中,黑色字体(去除 pandoc 默认蓝色) +2. "Table of Contents" 替换为中文"目录" +3. 目录页单独一页 +4. 一级标题(简介、启示等)前自动分页 +5. 表格保持完整不跨页断开 +6. 代码块保持完整不断开 +7. 日期行居中 + +用法: +```bash +python .opencode/skills/deep-research/scripts/format_docx.py "输入.docx" ["输出.docx"] +``` + +## 完整调研示例 + +用户输入: +> 调研下 Ralph Loop + +执行流程: + +```bash +# 1. 创建主题目录 +mkdir -p "{output_dir}/Ralph-Loop/images" + +# 2. 获取内容(如有 URL) +webfetch https://example.com/article + +# 3. 深度调研(使用 Task 工具联网搜索) + +# 4. 生成信息图 +python .opencode/skills/image-service/scripts/text_to_image.py "技术架构图..." --output "{output_dir}/Ralph-Loop/images/architecture.png" + +# 5. 撰写报告 +# 写入 {output_dir}/Ralph-Loop/Ralph-Loop调研报告.md + +# 6. 导出 Word(不使用 --toc,Markdown 已有手写目录) +cd "{output_dir}/Ralph-Loop" +pandoc "Ralph-Loop调研报告.md" -o "Ralph-Loop调研报告.docx" --resource-path=. +python ../../../.opencode/skills/deep-research/scripts/format_docx.py "Ralph-Loop调研报告.docx" +``` + +输出文件: +``` +{output_dir}/Ralph-Loop/ +├── images/ +│ ├── architecture.png +│ └── comparison.png +├── Ralph-Loop调研报告.md +└── Ralph-Loop调研报告.docx +``` + +## 依赖 + +- pandoc:Markdown 转 Word +- python-docx:Word 格式化 +- image-service 技能:生成信息图 diff --git a/deep-research/scripts/format_docx.py b/deep-research/scripts/format_docx.py new file mode 100644 index 0000000..d1961a5 --- /dev/null +++ b/deep-research/scripts/format_docx.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +""" +Word 文档格式化脚本 + +Author: 翟星人 + +功能: +1. 标题居中,黑色字体,加粗 +2. 在日期后插入目录 +3. 一级标题前分页 +4. 表格实线边框,不跨页断开 +5. 日期居中 +6. 图片说明小字居中 +7. 1/2/3级标题加粗 +8. 附录参考文献左对齐 +""" + +import sys +import re +from docx import Document +from docx.shared import Pt, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.oxml.ns import qn +from docx.oxml import OxmlElement + + +def add_page_break_before(paragraph): + """在段落前添加分页符""" + p = paragraph._p + pPr = p.get_or_add_pPr() + pageBreakBefore = OxmlElement('w:pageBreakBefore') + pPr.insert(0, pageBreakBefore) + + +def set_table_border(table): + """设置表格实线边框""" + tbl = table._tbl + tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr') + + tblBorders = OxmlElement('w:tblBorders') + + for border_name in ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']: + border = OxmlElement(f'w:{border_name}') + border.set(qn('w:val'), 'single') + border.set(qn('w:sz'), '4') + border.set(qn('w:space'), '0') + border.set(qn('w:color'), '000000') + tblBorders.append(border) + + tblPr.append(tblBorders) + if tbl.tblPr is None: + tbl.insert(0, tblPr) + + +def keep_table_together(table): + """保持表格不跨页断开""" + for row in table.rows: + for cell in row.cells: + for paragraph in cell.paragraphs: + pPr = paragraph._p.get_or_add_pPr() + keepNext = OxmlElement('w:keepNext') + keepLines = OxmlElement('w:keepLines') + pPr.append(keepNext) + pPr.append(keepLines) + + +def keep_paragraph_together(paragraph): + """保持段落不断开""" + pPr = paragraph._p.get_or_add_pPr() + keepNext = OxmlElement('w:keepNext') + keepLines = OxmlElement('w:keepLines') + pPr.append(keepNext) + pPr.append(keepLines) + + +def set_heading_style(paragraph, level=1): + """设置标题样式:黑色加粗""" + for run in paragraph.runs: + run.font.color.rgb = RGBColor(0, 0, 0) + run.font.bold = True + if level == 1: + run.font.size = Pt(16) + elif level == 2: + run.font.size = Pt(14) + elif level == 3: + run.font.size = Pt(12) + + +def set_caption_style(paragraph): + """设置图片说明样式:小字居中""" + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + for run in paragraph.runs: + run.font.size = Pt(9) + run.font.color.rgb = RGBColor(80, 80, 80) + + +def is_image_caption(text, prev_has_image): + """判断是否为图片说明""" + if prev_has_image and text and len(text) < 100: + # 必须以特定词开头才算图片说明 + if text.startswith("上图") or text.startswith("图:") or text.startswith("图:"): + return True + return False + + +def paragraph_has_image(paragraph): + """检查段落是否包含图片""" + for run in paragraph.runs: + if run._element.xpath('.//w:drawing') or run._element.xpath('.//w:pict'): + return True + return False + + +def is_horizontal_rule(paragraph): + """检查是否为分割线(文本或绘图元素)""" + text = paragraph.text.strip() + # 检查文本形式的分割线 + if text == "---" or text == "***" or text == "___" or (len(text) > 0 and all(c == '-' for c in text)): + return True + # 检查 pandoc 生成的绘图形式水平线(包含 line 或 rect 且文本为空,但不包含图片) + if text == "": + xml_str = paragraph._p.xml + has_drawing = 'w:pict' in xml_str or 'w:drawing' in xml_str + has_line = 'v:line' in xml_str or 'v:rect' in xml_str or ' [output.docx]") + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] if len(sys.argv) > 2 else None + + format_docx(input_file, output_file) diff --git a/image-service/README.md b/image-service/README.md new file mode 100644 index 0000000..6b77495 --- /dev/null +++ b/image-service/README.md @@ -0,0 +1,27 @@ +# Image Service + +图像生成/编辑/分析服务。 + +## 依赖 + +```bash +pip install httpx pillow numpy +``` + +## 配置 + +编辑 `config/settings.json` 或设置环境变量: + +```bash +export IMAGE_API_KEY="your_key" +export IMAGE_API_BASE_URL="https://api.openai.com/v1" +export VISION_API_KEY="your_key" +export VISION_API_BASE_URL="https://api.openai.com/v1" +``` + +## 功能 + +- 文生图 (text_to_image.py) +- 图生图 (image_to_image.py) +- 图片理解 (image_to_text.py) +- 长图拼接 (merge_long_image.py) diff --git a/image-service/SKILL.md b/image-service/SKILL.md new file mode 100644 index 0000000..c84f3c8 --- /dev/null +++ b/image-service/SKILL.md @@ -0,0 +1,132 @@ +--- +name: image-service +description: 多模态图像处理技能,支持文生图、图生图、图生文、长图拼接。当用户提到图片、图像、生成图、信息图、OCR 等关键词时触发。 +--- + +# 图像处理技能 + +## 概述 + +| 能力 | 说明 | 脚本 | +|-----|------|------| +| 文生图 | 根据中文文本描述生成图片 | `scripts/text_to_image.py` | +| 图生图 | 在已有图片基础上进行编辑 | `scripts/image_to_image.py` | +| 图生文 | 分析图片内容(描述、OCR、图表等) | `scripts/image_to_text.py` | +| 长图拼接 | 将多张图片垂直拼接为微信长图 | `scripts/merge_long_image.py` | +| 调研配图 | 预设手绘风格的调研报告信息图 | `scripts/research_image.py` | + +## 配置 + +配置文件:`config/settings.json` + +| 配置项 | 值 | +|-------|-----| +| IMAGE_API_BASE_URL | `${IMAGE_API_BASE_URL}` | +| IMAGE_MODEL | `lyra-flash-9` | +| VISION_MODEL | `qwen2.5-vl-72b-instruct` | + +## 执行规范 + +**图片默认保存到命令执行时的当前工作目录**: + +1. **不要**使用 `workdir` 切换到 skill 目录执行命令 +2. **始终**在用户的工作目录下执行,使用脚本的绝对路径 +3. 脚本路径:`.opencode/skills/image-service/scripts/` + +```bash +# 正确示例 +python .opencode/skills/image-service/scripts/text_to_image.py "描述" -r 3:4 -o output.png +``` + +## 快速使用 + +### 文生图 + +```bash +python .opencode/skills/image-service/scripts/text_to_image.py "信息图风格,标题:AI技术趋势" -r 16:9 +python .opencode/skills/image-service/scripts/text_to_image.py "竖版海报,产品展示" -r 3:4 -o poster.png +``` + +参数:`-r` 宽高比 | `-s` 尺寸 | `-o` 输出路径 + +支持比例:`1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` + +### 图生图 + +```bash +python .opencode/skills/image-service/scripts/image_to_image.py input.png "编辑描述" -r 3:4 +``` + +### 图生文 + +```bash +python .opencode/skills/image-service/scripts/image_to_text.py image.jpg -m describe +python .opencode/skills/image-service/scripts/image_to_text.py screenshot.png -m ocr +``` + +模式:`describe` | `ocr` | `chart` | `fashion` | `product` | `scene` + +### 长图拼接 + +```bash +python .opencode/skills/image-service/scripts/merge_long_image.py img1.png img2.png -o output.png --blend 20 +python .opencode/skills/image-service/scripts/merge_long_image.py -p "*.png" -o long.png --sort name +``` + +参数:`-p` 通配符 | `-o` 输出 | `-w` 宽度 | `-g` 间隔 | `--blend` 融合 | `--sort` 排序 + +### 调研配图 + +```bash +python .opencode/skills/image-service/scripts/research_image.py -t arch -n "标题" -c "内容" -o output.png +``` + +类型:`arch` 架构图 | `flow` 流程图 | `compare` 对比图 | `concept` 概念图 + +## 执行前必做:需求类型判断(铁律) + +**收到图片生成需求后,必须先判断是哪种类型,再决定执行方式:** + +### 长图识别规则 + +提示词中出现以下任一特征,即判定为**长图需求**: + +| 特征类型 | 识别关键词/模式 | +|---------|---------------| +| **明确声明** | 长图、长图海报、垂直长图、微信长图、Infographic、Long Banner | +| **分段结构** | 提示词包含多个段落(如"第1部分"、"顶部"、"中间"、"底部")| +| **编号列表** | 使用 `### 1.`、`### 2.` 等编号分段 | +| **多屏内容** | 描述了3个及以上独立画面/模块 | +| **从上至下** | 出现"从上至下"、"从上到下"等描述 | + +### 判断后的执行路径 + +``` +识别为长图 → 必须先读取 references/long-image-guide.md → 按长图流程执行 +识别为单图 → 直接使用 text_to_image.py 生成 +``` + +**铁律:识别为长图后,禁止直接生成!必须先加载长图指南,按指南流程执行。** + +## 详细指南(按需加载) + +| 场景 | 触发条件 | 参考文档 | +|------|---------|---------| +| 生成多屏长图 | 命中上述长图识别规则 | `references/long-image-guide.md`(必须加载)| +| 图片含中文文字 | 提示词要求图片包含中文标题/文字 | `references/text-rendering-guide.md` | +| 为 PPT/文档配图 | 用户提供了配色要求或参考文档 | `references/color-sync-guide.md` | +| API 接口细节 | 需要了解底层实现 | `docs/api-reference.md` | +| 提示词技巧 | 需要优化提示词效果 | `docs/prompt-guide.md` | + +## 提示词要点 + +1. **必须使用中文**撰写提示词 +2. 图片中的标题、标签**必须为中文** +3. 默认宽高比 **16:9**,可通过 `-r` 参数调整 +4. 推荐风格:信息图、数据可视化、手绘文字、科技插画 + +## 触发关键词 + +- **生成类**:生成图片、创建图片、文生图、图生图、信息图、数据可视化 +- **分析类**:分析图片、OCR、识别文字、图生文 +- **拼接类**:长图、微信长图、拼接图片 diff --git a/image-service/config/settings.json b/image-service/config/settings.json new file mode 100644 index 0000000..765032f --- /dev/null +++ b/image-service/config/settings.json @@ -0,0 +1,42 @@ +{ + "image_api": { + "key": "your_image_api_key", + "base_url": "https://api.openai.com/v1", + "model": "dall-e-3" + }, + "vision_api": { + "key": "your_vision_api_key", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o" + }, + "defaults": { + "text_to_image": { + "size": "1792x1024", + "response_format": "b64_json" + }, + "image_to_image": { + "size": "1792x1024", + "response_format": "b64_json" + }, + "image_to_text": { + "max_tokens": 2000, + "temperature": 0.7, + "mode": "describe" + } + }, + "limits": { + "max_file_size_mb": 4, + "supported_formats": ["png", "jpg", "jpeg", "webp", "gif"], + "max_prompt_length": 1000, + "timeout_seconds": { + "text_to_image": 180, + "image_to_image": 180, + "image_to_text": 120 + } + }, + "retry": { + "max_attempts": 3, + "backoff_multiplier": 2, + "initial_delay_seconds": 1 + } +} diff --git a/image-service/docs/api-reference.md b/image-service/docs/api-reference.md new file mode 100644 index 0000000..202ea0f --- /dev/null +++ b/image-service/docs/api-reference.md @@ -0,0 +1,233 @@ +# API 参考文档 + +## 概述 + +本技能使用两套 API: +1. **Lyra Flash API** - 用于图像生成和编辑(文生图、图生图) +2. **Qwen2.5-VL API** - 用于视觉识别(图生文) + +--- + +## 一、Lyra Flash API(图像生成) + +### 1.1 基础配置 + +| 配置项 | 值 | +|-------|-----| +| Base URL | `${IMAGE_API_BASE_URL}` | +| Model | `lyra-flash-9` | +| 认证方式 | Bearer Token | + +### 1.2 文生图接口 + +**端点** +``` +POST /images/generations +``` + +**请求头** +```json +{ + "Content-Type": "application/json", + "Authorization": "Bearer ${IMAGE_API_KEY}" +} +``` + +**请求体** +```json +{ + "model": "lyra-flash-9", + "prompt": "中文图像描述", + "size": "1792x1024", + "response_format": "b64_json" +} +``` + +**参数说明** + +| 参数 | 类型 | 必填 | 说明 | +|-----|------|-----|------| +| model | string | 是 | 固定使用 `lyra-flash-9` | +| prompt | string | 是 | 中文图像生成提示词 | +| size | string | 否 | 图片尺寸,默认 `1792x1024` | +| response_format | string | 否 | 响应格式,推荐 `b64_json` | + +**响应体** +```json +{ + "created": 1641234567, + "data": [ + { + "b64_json": "base64编码的图片数据" + } + ] +} +``` + +### 1.3 图生图接口 + +**端点** +``` +POST /images/edits +``` + +**请求体** +```json +{ + "model": "lyra-flash-9", + "prompt": "中文编辑指令", + "image": "data:image/png;base64,{base64数据}", + "size": "1792x1024", + "response_format": "b64_json" +} +``` + +**参数说明** + +| 参数 | 类型 | 必填 | 说明 | +|-----|------|-----|------| +| model | string | 是 | 固定使用 `lyra-flash-9` | +| prompt | string | 是 | 中文图片编辑指令 | +| image | string | 是 | Base64 编码的参考图片(含 data URL 前缀) | +| size | string | 否 | 输出尺寸 | +| response_format | string | 否 | 响应格式 | + +**响应体** +```json +{ + "data": [ + { + "b64_json": "base64编码的生成图片" + } + ] +} +``` + +--- + +## 二、Qwen2.5-VL API(视觉识别) + +### 2.1 基础配置 + +| 配置项 | 值 | +|-------|-----| +| Base URL | `${IMAGE_API_BASE_URL}` | +| Model | `qwen2.5-vl-72b-instruct` | +| 认证方式 | Bearer Token | + +### 2.2 图生文接口 + +**端点** +``` +POST /chat/completions +``` + +**请求头** +```json +{ + "Content-Type": "application/json", + "Authorization": "Bearer ${VISION_API_KEY}" +} +``` + +**请求体** +```json +{ + "model": "qwen2.5-vl-72b-instruct", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "请描述这张图片" + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64,{base64数据}" + } + } + ] + } + ], + "max_tokens": 2000, + "temperature": 0.7 +} +``` + +**参数说明** + +| 参数 | 类型 | 必填 | 说明 | +|-----|------|-----|------| +| model | string | 是 | 视觉模型名称 | +| messages | array | 是 | 消息列表,包含文本和图片 | +| max_tokens | int | 否 | 最大输出 token 数 | +| temperature | float | 否 | 温度参数(0-1) | + +**响应体** +```json +{ + "id": "chatcmpl-xxx", + "object": "chat.completion", + "created": 1641234567, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "这是一张..." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + } +} +``` + +--- + +## 三、错误码说明 + +| 状态码 | 说明 | 处理建议 | +|-------|------|---------| +| 400 | 请求参数错误 | 检查请求体格式和参数 | +| 401 | API 密钥无效 | 检查 API Key 是否正确 | +| 403 | 权限不足 | 检查 API Key 权限 | +| 429 | 请求频率限制 | 等待后重试 | +| 500 | 服务器内部错误 | 稍后重试 | +| 503 | 服务不可用 | 稍后重试 | + +--- + +## 四、最佳实践 + +### 4.1 超时设置 + +- 文生图:建议 120-180 秒 +- 图生图:建议 180-300 秒 +- 图生文:建议 60-120 秒 + +### 4.2 重试策略 + +建议实现指数退避重试: +1. 首次重试:等待 1 秒 +2. 第二次重试:等待 2 秒 +3. 第三次重试:等待 4 秒 + +### 4.3 图片格式 + +- 支持格式:PNG、JPG、JPEG、WebP、GIF +- 推荐格式:PNG(无损)或 JPEG(有损但体积小) +- 最大文件大小:建议不超过 4MB + +### 4.4 Base64 编码 + +图片必须使用完整的 Data URL 格式: +``` +data:image/png;base64,iVBORw0KGgo... +``` diff --git a/image-service/docs/prompt-guide.md b/image-service/docs/prompt-guide.md new file mode 100644 index 0000000..8cf5ac4 --- /dev/null +++ b/image-service/docs/prompt-guide.md @@ -0,0 +1,215 @@ +# 提示词指南 + +## 概述 + +本指南提供文生图、图生图和图生文三种场景的提示词编写规范和最佳实践。 + +--- + +## 一、文生图提示词 + +### 1.1 基本规则 + +1. **必须使用中文**撰写提示词 +2. 图片中的标题、说明、标签**必须为中文** +3. 默认尺寸为 **16:9(1792x1024)** +4. 结构化描述效果更好 + +### 1.2 标准模板 + +``` +[风格类型],[艺术效果],[分辨率]。 +标题:[中文标题]。 +视觉元素:[主体对象、结构、场景描述]。 +配色:[主色调方案]。 +类型:[具体类型]。 +``` + +### 1.3 推荐风格 + +| 风格 | 适用场景 | +|-----|---------| +| 信息图风格 | 数据展示、流程说明 | +| 数据可视化 | 图表、统计数据 | +| 手绘文字风格 | 笔记、教程 | +| 科技插画风 | 技术文章配图 | +| 扁平化设计 | UI/UX 展示 | +| 3D 渲染风格 | 产品展示 | + +### 1.4 示例 + +**信息图类** +``` +信息图风格插图,手绘文字风格,高清16:9。 +标题:AI技术发展趋势。 +视觉元素:中央AI芯片图标,周围连接云计算、大数据、机器学习图标。 +配色:科技蓝和白色。 +类型:信息图。 +``` + +**数据可视化类** +``` +数据可视化风格,中文标注,高清16:9。 +标题:2026年AI投资趋势。 +视觉元素:柱状图、增长箭头、美元符号。 +配色:金色和科技蓝。 +类型:数据可视化。 +``` + +**产品展示类** +``` +3D产品渲染风格,光影效果,高清16:9。 +标题:智能手表新品发布。 +视觉元素:手表主体居中,周围展示核心功能图标。 +配色:深空灰和玫瑰金。 +类型:产品展示。 +``` + +--- + +## 二、图生图提示词 + +### 2.1 基本规则 + +1. 明确指出**保留什么**和**修改什么** +2. 描述**目标风格**和**期望效果** +3. 提供具体的**细节要求** + +### 2.2 标准模板 + +``` +基于原图进行编辑,[编辑描述]。 +保持:[需要保留的元素]。 +修改:[需要修改的部分]。 +风格:[目标风格]。 +细节:[具体的细节要求]。 +``` + +### 2.3 编辑类型 + +| 类型 | 说明 | 示例 | +|-----|------|-----| +| 风格迁移 | 改变整体风格 | 转为油画风格 | +| 背景替换 | 更换背景 | 将背景改为海滩 | +| 元素添加 | 添加新元素 | 添加文字标题 | +| 元素删除 | 移除元素 | 删除背景人物 | +| 色调调整 | 改变颜色 | 转为暖色调 | +| 质量增强 | 提升质量 | 增加细节和清晰度 | + +### 2.4 示例 + +**风格迁移** +``` +基于原图进行编辑,将整体风格改为科技蓝色调的信息图。 +保持:主体元素和构图。 +修改:所有文字替换为中文标注,背景改为深蓝渐变。 +风格:现代科技感信息图。 +细节:添加数据流动效果和光点装饰。 +``` + +**人物编辑** +``` +基于原图进行编辑,将人物转换为3D科幻风格。 +保持:人物姿态和面部特征。 +修改:服装改为未来感战斗服,增加全息UI界面。 +风格:类似钢铁侠贾维斯系统。 +细节:添加蓝色全息光效和数据面板。 +``` + +**背景替换** +``` +基于原图进行编辑,替换背景为深色科技空间。 +保持:原图主体比例和清晰度。 +修改:背景完全替换,添加中文标题与数据标签。 +风格:深色科技风格。 +细节:背景添加星空和网格线条。 +``` + +--- + +## 三、图生文提示词 + +### 3.1 分析模式 + +| 模式 | 用途 | 提示词 | +|-----|------|-------| +| describe | 通用描述 | 详细描述图片内容 | +| ocr | 文字识别 | 识别图片中的所有文字 | +| chart | 图表分析 | 分析图表数据和趋势 | +| fashion | 穿搭分析 | 分析人物服装搭配 | +| product | 产品分析 | 分析产品特征 | +| scene | 场景分析 | 描述场景环境 | + +### 3.2 自定义提示词示例 + +**详细描述** +``` +请详细描述这张图片的内容,包括: +1. 人物特征和表情 +2. 服装样式和颜色 +3. 画面布局和构图 +4. 艺术风格或摄影风格 +5. 任何文字标注或说明 +6. 背景环境和其他细节 +``` + +**OCR识别** +``` +请仔细识别这张图片中的所有文字内容,包括: +1. 标题和副标题 +2. 正文内容 +3. 图表标签 +4. 按钮文字 +5. 其他任何可见的文字 + +请按照文字在图片中的位置顺序,以清晰的格式输出识别结果。 +``` + +**图表分析** +``` +请分析这张图表的内容,包括: +1. 图表类型(柱状图、折线图、饼图等) +2. 主要数据趋势 +3. 关键数据点 +4. 图表标题和标签 +5. 数据的结论或洞察 + +请用中文详细描述图表传达的信息。 +``` + +**穿搭分析** +``` +请分析这张图片中人物的穿搭,包括: +1. 上装:款式、颜色、材质 +2. 下装:款式、颜色、材质 +3. 鞋履:类型、颜色 +4. 配饰:包包、帽子、眼镜、饰品等 +5. 整体风格:休闲/商务/运动/时尚等 +6. 搭配建议和点评 +``` + +--- + +## 四、最佳实践 + +### 4.1 提示词优化技巧 + +1. **具体明确**:避免模糊描述,使用具体词汇 +2. **结构清晰**:使用分点或模板结构 +3. **重点突出**:将最重要的要求放在前面 +4. **适度详细**:提供足够细节但不要过于冗长 + +### 4.2 常见问题 + +| 问题 | 原因 | 解决方案 | +|-----|------|---------| +| 生成结果与描述不符 | 提示词不够具体 | 添加更多细节描述 | +| 中文显示异常 | 未强调中文要求 | 明确指定"中文标注" | +| 风格不统一 | 风格描述模糊 | 使用具体的风格参考 | +| 元素缺失 | 未明确列出元素 | 逐一列出所需元素 | + +### 4.3 提示词长度建议 + +- 文生图:100-300 字 +- 图生图:50-200 字 +- 图生文:50-150 字 diff --git a/image-service/references/color-sync-guide.md b/image-service/references/color-sync-guide.md new file mode 100644 index 0000000..7426556 --- /dev/null +++ b/image-service/references/color-sync-guide.md @@ -0,0 +1,76 @@ +# 配色协同机制 + +当 image-service 与其他 skill 配合使用时(如 pptx、docx、obsidian 等),**必须感知上下文配色方案并自动适配**,确保生成的图片与目标载体风格统一。 + +## 协同原则 + +1. **主动感知**:生成配图前,先确认目标载体的配色方案 +2. **自动适配**:将配色信息融入图片生成提示词 +3. **风格统一**:背景色、主色调、强调色保持一致 + +## 配色来源优先级 + +| 优先级 | 来源 | 说明 | +|-------|------|------| +| 1 | 用户明确指定 | 用户直接提供的颜色值 | +| 2 | 当前任务上下文 | 正在制作的 PPT/文档的配色方案 | +| 3 | 项目配置文件 | `.design/palette.json` 或类似配置 | +| 4 | 默认风格 | 手绘白底风格(无特殊要求时) | + +## 与 PPTX 协同 + +制作 PPT 配图时,从 pptx skill 的设计方案中提取配色: + +```markdown +# 示例:PPT 配色方案 +- 背景色:#181B24(深蓝黑) +- 主色:#B165FB(紫色) +- 辅助色:#40695B(翡翠绿) +- 文字色:#FFFFFF / #AAAAAA +``` + +生成图片时,将配色融入提示词: + +```bash +# 错误示例(不考虑配色) +python scripts/text_to_image.py "流程图,用户路径变化" -r 16:9 + +# 正确示例(融入配色) +python scripts/text_to_image.py "信息图风格,深色背景#181B24,科技感流程图。用紫色#B165FB和翡翠绿#40695B作为强调色,展示用户路径变化,发光线条风格,中文标签" -r 16:9 +``` + +## 与其他 Skill 协同 + +| 目标载体 | 配色来源 | 适配要点 | +|---------|---------|---------| +| **PPTX** | HTML slides 的 CSS 配色 | 背景色、强调色、文字色统一 | +| **DOCX** | 文档主题色或用户指定 | 配合文档正式/活泼风格 | +| **Obsidian** | Vault 主题(深色/浅色) | 适配笔记阅读体验 | +| **小红书** | 品牌色或内容调性 | 竖版 3:4,吸睛配色 | +| **调研报告** | 统一手绘风格 | 使用 research_image.py 预设 | + +## 配色提示词模板 + +``` +信息图风格,{背景描述}背景{背景色},{风格描述}。 +使用{主色}作为主色调,{辅助色}作为辅助色。 +{内容描述},{视觉风格},中文标签。 +``` + +**示例**: +``` +信息图风格,深色背景#181B24,科技感对比图。 +使用紫色#B165FB作为主色调,翡翠绿#40695B作为辅助色。 +左侧展示SEO特点,右侧展示GEO特点,发光连接线风格,中文标签。 +``` + +## Agent 执行规范 + +1. **识别协同场景**:检测是否在为其他 skill 生成配图 +2. **提取配色方案**:从上下文/HTML/配置中获取颜色值 +3. **构建适配提示词**:将配色信息自然融入生成描述 +4. **验证风格一致**:生成后确认与目标载体视觉协调 + +## 协同执行流程 + +1. 确认目标载体 → 2. 提取配色方案 → 3. 融入提示词 → 4. 生成适配图片 diff --git a/image-service/references/long-image-guide.md b/image-service/references/long-image-guide.md new file mode 100644 index 0000000..e4e2f4a --- /dev/null +++ b/image-service/references/long-image-guide.md @@ -0,0 +1,135 @@ +# 长图生成规范 + +生成需要拼接的长图时,采用**叠罗汉式串行生成**,每张图参考上一张图生成,确保风格一致、衔接自然。 + +## 铁律:执行前必须分析+确认 + +**收到长图需求后,禁止直接开始生成!必须先完成以下步骤:** + +### 第一步:分析提示词结构 + +仔细阅读提示词,识别以下信息: +1. **分屏数量**:提示词中有几个明确的段落/模块? +2. **每屏内容**:每一屏具体要展示什么? +3. **全局风格**:色调、风格、光影等统一要素 +4. **衔接元素**:段落之间用什么元素过渡? + +### 第二步:输出分屏规划表 + +必须用表格形式输出规划,让用户一目了然: + +```markdown +| 屏数 | 内容概要 | 关键元素 | +|-----|---------|---------| +| 1 | 主视觉+标题 | xxx | +| 2 | xxx特写 | xxx | +| ... | ... | ... | + +**全局风格**:xxx风格、xxx色调、xxx布光 +**输出比例**:3:4 +**预计生成**:N张图 → 拼接为长图 +``` + +### 第三步:等待用户确认 + +**必须等用户说"OK"、"开始"、"没问题"后才能开始生成!** + +用户可能会: +- 调整分屏数量 +- 修改某屏内容 +- 补充遗漏的要素 + +## 核心原则:叠罗汉式串行生成 + +**为什么用串行而不是并发?** +- 每张图的顶部颜色需要与上一张图的底部颜色衔接 +- 只有等上一张图生成完成,才能提取其底部色调 +- 串行生成确保每一屏之间的过渡自然无缝 + +**为什么参考上一张而不是首图?** +- 参考首图会导致中间屏幕风格跳跃 +- 叠罗汉式参考让风格逐屏延续,过渡更平滑 +- 每张图只需关心与相邻图的衔接 + +## 生成前校验清单 + +| 检查项 | 要求 | 示例 | +|-------|------|------| +| **比例统一** | 所有分图使用相同 `-r` 参数 | 全部 `-r 3:4` | +| **风格描述统一** | 使用相同的风格关键词 | 全部 `电影级美食摄影风格` | +| **色调统一** | 定义主色调范围 | 全部 `深红色、暖棕色、金色` | + +## Agent 执行流程(铁律) + +``` +1. 收到长图需求 +2. 【分析】仔细阅读提示词,识别分屏结构 +3. 【规划】输出分屏规划表(表格形式) +4. 【确认】等待用户确认后才开始生成(铁律!) +5. 定义全局风格变量(主色调、风格词) +6. 串行生成每一屏: + a. 首屏:用 text_to_image.py 生成,定调 + b. 第2屏:用 image_to_image.py 参考第1屏生成 + c. 第3屏:用 image_to_image.py 参考第2屏生成 + d. 以此类推...每屏参考上一屏 +7. 每屏生成后等待完成,再生成下一屏(串行,不可并发) +8. 全部完成后,使用 --blend 20 拼接输出 +``` + +## 图生图 Prompt 规范 + +**核心要点:顶部衔接上一张底部** + +后续图片的 prompt 必须包含: +1. **顶部衔接声明**:明确顶部颜色/氛围与上一张底部衔接 +2. **风格继承**:参考上一张图的整体风格、光影 +3. **本屏内容**:描述当前屏幕要展示的内容 + +**Prompt 模板:** +``` +参考模板图的整体风格、色调和光影氛围。本屏顶部与上一屏底部自然衔接。{本屏具体内容描述} +``` + +**更精确的写法(推荐):** +``` +参考模板图的{风格}、{色调}、{光影}。顶部延续上一屏底部的{颜色/氛围}。{本屏具体内容描述} +``` + +## 分屏位置规范 + +| 位置 | 处理方式 | +|------|---------| +| **首屏** | 顶部正常开始,底部内容自然过渡(无需刻意留白) | +| **中间屏** | 顶部衔接上一屏底部颜色,底部内容自然过渡 | +| **尾屏** | 顶部衔接上一屏底部颜色,底部正常收尾 | + +**关键:不要预留固定百分比的留白区域,让内容自然过渡即可** + +## 执行示例 + +```bash +# 步骤1:生成首屏(文生图,定调) +python .opencode/skills/image-service/scripts/text_to_image.py "高端美食摄影风格,深红暖棕金色调,电影级布光..." -r 3:4 -o 01_hero.png +# 等待完成 + +# 步骤2:生成第2屏(参考第1屏) +python .opencode/skills/image-service/scripts/image_to_image.py 01_hero.png "参考模板图的美食摄影风格、深红暖棕色调、电影级布光。顶部延续上一屏底部的暖色氛围。本屏内容:酥皮特写..." -r 3:4 -o 02_crisp.png +# 等待完成 + +# 步骤3:生成第3屏(参考第2屏) +python .opencode/skills/image-service/scripts/image_to_image.py 02_crisp.png "参考模板图的美食摄影风格、深红暖棕色调、电影级布光。顶部延续上一屏底部的色调。本屏内容:牛排特写..." -r 3:4 -o 03_tenderloin.png +# 等待完成 + +# ...以此类推 + +# 最后:拼接(推荐 blend 20) +python .opencode/skills/image-service/scripts/merge_long_image.py 01_hero.png 02_crisp.png 03_tenderloin.png ... -o final.png --blend 20 +``` + +## 铁律 + +1. **必须串行生成**:每屏生成完成后再生成下一屏,禁止并发 +2. **叠罗汉式参考**:第N屏参考第N-1屏,不是全部参考首屏 +3. **顶部衔接**:每屏的顶部颜色/氛围必须与上一屏底部衔接 +4. **不留固定留白**:不要预留4%/8%等固定留白,让内容自然过渡 +5. **脚本区分**:首屏用 `text_to_image.py`,后续全部用 `image_to_image.py` diff --git a/image-service/references/text-rendering-guide.md b/image-service/references/text-rendering-guide.md new file mode 100644 index 0000000..bbaf6b0 --- /dev/null +++ b/image-service/references/text-rendering-guide.md @@ -0,0 +1,41 @@ +# 文字清晰规范 + +生成包含中文文字的图片时,**必须在 prompt 末尾追加文字清晰指令**,确保文字可读、无乱码。 + +## 文字清晰后缀(必加) + +``` +【文字渲染要求】 +- 所有中文文字必须清晰可读,笔画完整,无模糊、无乱码、无伪文字 +- 文字边缘锐利,呈现印刷级清晰度,彻底消除压缩噪点与边缘溢色 +- 字体风格统一,字距适中,排版规整 +- 严禁出现无法阅读的乱码字符或残缺笔画 +``` + +## 完整 Prompt 结构 + +``` +{风格描述}。{内容描述}。{布局描述}。 + +【文字渲染要求】 +- 所有中文文字必须清晰可读,笔画完整,无模糊、无乱码、无伪文字 +- 文字边缘锐利,呈现印刷级清晰度 +- 字体风格统一,排版规整 +``` + +## 生成后校验流程 + +1. 生成图片后,用 `image_to_text.py -m ocr` 校验文字是否清晰 +2. 如果 OCR 识别结果与预期文字不符,使用图生图迭代修复 +3. 修复 prompt 使用以下模板 + +## 文字修复 Prompt(图生图迭代修复用) + +``` +执行语意级图像重构。针对图中模糊或乱码的文字区域进行修复: +1. 保持原图的版面配置、物体座标、配色风格完全不变 +2. 将模糊文字修复为清晰的简体中文:{预期文字内容} +3. 文字笔画必须呈现印刷级清晰度,边缘锐利,无压缩噪点 +4. 严禁产生无法阅读的伪文字或乱码 +直接输出修复后的图像。 +``` diff --git a/image-service/scripts/image_to_image.py b/image-service/scripts/image_to_image.py new file mode 100644 index 0000000..57e963a --- /dev/null +++ b/image-service/scripts/image_to_image.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +""" +图生图脚本 (Image-to-Image) +使用 Lyra Flash API 基于参考图片和中文指令进行图片编辑 + +Author: 翟星人 +""" + +import httpx +import base64 +import json +import os +from typing import Dict, Any, Optional, Union +from pathlib import Path + +VALID_ASPECT_RATIOS = [ + "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9" +] + +VALID_SIZES = [ + "1024x1024", + "1536x1024", "1792x1024", "1344x768", "1248x832", "1184x864", "1152x896", "1536x672", + "1024x1536", "1024x1792", "768x1344", "832x1248", "864x1184", "896x1152" +] + +RATIO_TO_SIZE = { + "1:1": "1024x1024", + "2:3": "832x1248", + "3:2": "1248x832", + "3:4": "1024x1536", + "4:3": "1536x1024", + "4:5": "864x1184", + "5:4": "1184x864", + "9:16": "1024x1792", + "16:9": "1792x1024", + "21:9": "1536x672" +} + + +class ImageToImageEditor: + """图生图编辑器""" + + def __init__(self, config: Optional[Dict[str, str]] = None): + """ + 初始化编辑器 + + Args: + config: 配置字典,包含 api_key, base_url, model + 如果不传则从环境变量或配置文件读取 + """ + if config is None: + config = self._load_config() + + self.api_key = config.get('api_key') or config.get('IMAGE_API_KEY') + self.base_url = config.get('base_url') or config.get('IMAGE_API_BASE_URL') + self.model = config.get('model') or config.get('IMAGE_MODEL') or 'lyra-flash-9' + + if not self.api_key or not self.base_url: + raise ValueError("缺少必要的 API 配置:api_key 和 base_url") + + def _load_config(self) -> Dict[str, str]: + """从配置文件或环境变量加载配置""" + config = {} + + # 尝试从配置文件加载 + config_path = Path(__file__).parent.parent / 'config' / 'settings.json' + if config_path.exists(): + with open(config_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + api_config = settings.get('image_api', {}) + config['api_key'] = api_config.get('key') + config['base_url'] = api_config.get('base_url') + config['model'] = api_config.get('model') + + # 环境变量优先级更高 + config['api_key'] = os.getenv('IMAGE_API_KEY', config.get('api_key')) + config['base_url'] = os.getenv('IMAGE_API_BASE_URL', config.get('base_url')) + config['model'] = os.getenv('IMAGE_MODEL', config.get('model')) + + return config + + @staticmethod + def image_to_base64(image_path: str, with_prefix: bool = True) -> str: + """ + 将图片文件转换为 base64 编码 + + Args: + image_path: 图片文件路径 + with_prefix: 是否添加 data URL 前缀 + + Returns: + base64 编码字符串 + """ + path = Path(image_path) + if not path.exists(): + raise FileNotFoundError(f"图片文件不存在: {image_path}") + + # 获取 MIME 类型 + suffix = path.suffix.lower() + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp' + } + mime_type = mime_types.get(suffix, 'image/png') + + with open(image_path, 'rb') as f: + b64_str = base64.b64encode(f.read()).decode('utf-8') + + if with_prefix: + return f"data:{mime_type};base64,{b64_str}" + return b64_str + + def edit( + self, + image: Union[str, bytes], + prompt: str, + aspect_ratio: Optional[str] = None, + size: Optional[str] = None, + output_path: Optional[str] = None, + response_format: str = "b64_json" + ) -> Dict[str, Any]: + """ + 编辑图片 + + Args: + image: 图片路径或 base64 字符串 + prompt: 中文编辑指令 + aspect_ratio: 宽高比 (如 3:4, 16:9) + size: 传统尺寸 (如 1024x1792) + output_path: 输出文件路径 + response_format: 响应格式 + + Returns: + 包含编辑结果的字典 + """ + # 处理图片输入 + if isinstance(image, str): + if os.path.isfile(image): + image_b64 = self.image_to_base64(image) + elif image.startswith('data:'): + image_b64 = image + else: + # 假设是纯 base64 字符串 + image_b64 = f"data:image/png;base64,{image}" + else: + image_b64 = f"data:image/png;base64,{base64.b64encode(image).decode('utf-8')}" + + payload: Dict[str, Any] = { + "model": self.model, + "prompt": prompt, + "image": image_b64, + "response_format": response_format + } + + # 确定尺寸:优先用 aspect_ratio 映射,其次用 size + if aspect_ratio: + payload["size"] = RATIO_TO_SIZE.get(aspect_ratio, "1024x1536") + elif size: + payload["size"] = size + else: + payload["size"] = "1024x1536" # 默认 3:4 + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + } + + try: + with httpx.Client(timeout=180.0) as client: + response = client.post( + f"{self.base_url}/images/edits", + headers=headers, + json=payload + ) + response.raise_for_status() + result = response.json() + + # 如果指定了输出路径,保存图片 + if output_path and result.get("data"): + b64_data = result["data"][0].get("b64_json") + if b64_data: + self._save_image(b64_data, output_path) + result["saved_path"] = output_path + + return { + "success": True, + "data": result, + "saved_path": output_path if output_path else None + } + + except httpx.HTTPStatusError as e: + return { + "success": False, + "error": f"HTTP 错误: {e.response.status_code}", + "detail": str(e) + } + except Exception as e: + return { + "success": False, + "error": "编辑失败", + "detail": str(e) + } + + def _save_image(self, b64_data: str, output_path: str) -> None: + """保存 base64 图片到文件""" + image_data = base64.b64decode(b64_data) + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + with open(output_path, 'wb') as f: + f.write(image_data) + + +def main(): + """命令行入口""" + import argparse + import time + + parser = argparse.ArgumentParser( + description='图生图编辑工具', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=f''' +尺寸参数说明: + -r/--ratio 宽高比(推荐),支持: {", ".join(VALID_ASPECT_RATIOS)} + -s/--size 传统尺寸,支持: {", ".join(VALID_SIZES[:4])}... + +示例: + python image_to_image.py input.png "编辑描述" -r 3:4 + python image_to_image.py input.png "编辑描述" -s 1024x1536 +''' + ) + parser.add_argument('image', help='输入图片路径') + parser.add_argument('prompt', help='中文编辑指令') + parser.add_argument('-o', '--output', help='输出文件路径(默认保存到当前目录)') + parser.add_argument('-r', '--ratio', help=f'宽高比(推荐)。可选: {", ".join(VALID_ASPECT_RATIOS)}') + parser.add_argument('-s', '--size', help='传统尺寸,如 1024x1536') + + args = parser.parse_args() + + if args.ratio and args.ratio not in VALID_ASPECT_RATIOS: + print(f"错误: 不支持的宽高比 '{args.ratio}'") + print(f"支持的宽高比: {', '.join(VALID_ASPECT_RATIOS)}") + return + + if args.size and args.size not in VALID_SIZES: + print(f"警告: 尺寸 '{args.size}' 可能不被支持") + + output_path = args.output + if not output_path: + timestamp = time.strftime("%Y%m%d_%H%M%S") + output_path = f"edited_{timestamp}.png" + + editor = ImageToImageEditor() + result = editor.edit( + image=args.image, + prompt=args.prompt, + aspect_ratio=args.ratio, + size=args.size, + output_path=output_path + ) + + if result["success"]: + print(f"编辑成功!") + if result.get("saved_path"): + print(f"图片已保存到: {result['saved_path']}") + else: + print(f"编辑失败: {result['error']}") + print(f"详情: {result.get('detail', 'N/A')}") + + +if __name__ == "__main__": + main() diff --git a/image-service/scripts/image_to_text.py b/image-service/scripts/image_to_text.py new file mode 100644 index 0000000..4ae66df --- /dev/null +++ b/image-service/scripts/image_to_text.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +""" +图生文脚本 (Image-to-Text) - 视觉识别 +使用 Qwen2.5-VL 模型分析图片内容并生成文字描述 + +Author: 翟星人 +""" + +import httpx +import base64 +import json +import os +from typing import Dict, Any, Optional, Union, List +from pathlib import Path + + +class ImageToTextAnalyzer: + """图生文分析器 - 视觉识别""" + + # 预定义的分析模式 + ANALYSIS_MODES = { + "describe": "请详细描述这张图片的内容,包括:人物、场景、物品、颜色、布局等所有细节。", + "ocr": "请仔细识别这张图片中的所有文字内容,按照文字在图片中的位置顺序输出。如果是中文,请保持原文输出。", + "chart": "请分析这张图表的内容,包括:图表类型、数据趋势、关键数据点、标题标签、以及数据的结论或洞察。", + "fashion": "请分析这张图片中人物的穿搭,包括:服装款式、颜色搭配、配饰、整体风格等。", + "product": "请分析这张产品图片,包括:产品类型、外观特征、功能特点、品牌信息等。", + "scene": "请描述这张图片的场景,包括:地点、环境、氛围、时间(白天/夜晚)等。" + } + + def __init__(self, config: Optional[Dict[str, str]] = None): + """ + 初始化分析器 + + Args: + config: 配置字典,包含 api_key, base_url, model + 如果不传则从环境变量或配置文件读取 + """ + if config is None: + config = self._load_config() + + self.api_key = config.get('api_key') or config.get('VISION_API_KEY') or config.get('IMAGE_API_KEY') + self.base_url = config.get('base_url') or config.get('VISION_API_BASE_URL') or config.get('IMAGE_API_BASE_URL') + self.model = config.get('model') or config.get('VISION_MODEL') or 'qwen2.5-vl-72b-instruct' + + if not self.api_key or not self.base_url: + raise ValueError("缺少必要的 API 配置:api_key 和 base_url") + + def _load_config(self) -> Dict[str, str]: + """从配置文件或环境变量加载配置""" + config = {} + + # 尝试从配置文件加载 + config_path = Path(__file__).parent.parent / 'config' / 'settings.json' + if config_path.exists(): + with open(config_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + # 优先使用 vision_api 配置 + vision_config = settings.get('vision_api', {}) + if vision_config: + config['api_key'] = vision_config.get('key') + config['base_url'] = vision_config.get('base_url') + config['model'] = vision_config.get('model') + else: + # 回退到 image_api 配置 + api_config = settings.get('image_api', {}) + config['api_key'] = api_config.get('key') + config['base_url'] = api_config.get('base_url') + + # 环境变量优先级更高 + config['api_key'] = os.getenv('VISION_API_KEY', os.getenv('IMAGE_API_KEY', config.get('api_key'))) + config['base_url'] = os.getenv('VISION_API_BASE_URL', os.getenv('IMAGE_API_BASE_URL', config.get('base_url'))) + config['model'] = os.getenv('VISION_MODEL', config.get('model', 'qwen2.5-vl-72b-instruct')) + + return config + + @staticmethod + def image_to_base64(image_path: str) -> str: + """ + 将图片文件转换为 base64 编码(带 data URL 前缀) + + Args: + image_path: 图片文件路径 + + Returns: + base64 编码字符串(含 data URL 前缀) + """ + path = Path(image_path) + if not path.exists(): + raise FileNotFoundError(f"图片文件不存在: {image_path}") + + # 获取 MIME 类型 + suffix = path.suffix.lower() + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp' + } + mime_type = mime_types.get(suffix, 'image/png') + + with open(image_path, 'rb') as f: + b64_str = base64.b64encode(f.read()).decode('utf-8') + + return f"data:{mime_type};base64,{b64_str}" + + def analyze( + self, + image: Union[str, bytes], + prompt: Optional[str] = None, + mode: str = "describe", + max_tokens: int = 2000, + temperature: float = 0.7 + ) -> Dict[str, Any]: + """ + 分析图片并生成文字描述 + + Args: + image: 图片路径、URL 或 base64 字符串 + prompt: 自定义分析提示词(如果提供则忽略 mode) + mode: 分析模式 (describe/ocr/chart/fashion/product/scene) + max_tokens: 最大输出 token 数 + temperature: 温度参数 + + Returns: + 包含分析结果的字典 + """ + # 确定使用的提示词 + if prompt is None: + prompt = self.ANALYSIS_MODES.get(mode, self.ANALYSIS_MODES["describe"]) + + # 处理图片输入 + if isinstance(image, str): + if os.path.isfile(image): + image_url = self.image_to_base64(image) + elif image.startswith('data:') or image.startswith('http'): + image_url = image + else: + # 假设是纯 base64 字符串 + image_url = f"data:image/png;base64,{image}" + else: + image_url = f"data:image/png;base64,{base64.b64encode(image).decode('utf-8')}" + + # 构建请求 + payload = { + "model": self.model, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": prompt + }, + { + "type": "image_url", + "image_url": { + "url": image_url + } + } + ] + } + ], + "max_tokens": max_tokens, + "temperature": temperature + } + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + } + + try: + with httpx.Client(timeout=120.0) as client: + response = client.post( + f"{self.base_url}/chat/completions", + headers=headers, + json=payload + ) + response.raise_for_status() + result = response.json() + + # 提取文本内容 + content = result.get("choices", [{}])[0].get("message", {}).get("content", "") + + return { + "success": True, + "content": content, + "mode": mode, + "usage": result.get("usage", {}) + } + + except httpx.HTTPStatusError as e: + return { + "success": False, + "error": f"HTTP 错误: {e.response.status_code}", + "detail": str(e) + } + except Exception as e: + return { + "success": False, + "error": "分析失败", + "detail": str(e) + } + + def describe(self, image: Union[str, bytes]) -> Dict[str, Any]: + """通用图片描述""" + return self.analyze(image, mode="describe") + + def ocr(self, image: Union[str, bytes]) -> Dict[str, Any]: + """文字识别 (OCR)""" + return self.analyze(image, mode="ocr") + + def analyze_chart(self, image: Union[str, bytes]) -> Dict[str, Any]: + """图表分析""" + return self.analyze(image, mode="chart") + + def analyze_fashion(self, image: Union[str, bytes]) -> Dict[str, Any]: + """穿搭分析""" + return self.analyze(image, mode="fashion") + + def analyze_product(self, image: Union[str, bytes]) -> Dict[str, Any]: + """产品分析""" + return self.analyze(image, mode="product") + + def analyze_scene(self, image: Union[str, bytes]) -> Dict[str, Any]: + """场景分析""" + return self.analyze(image, mode="scene") + + def batch_analyze( + self, + images: List[str], + mode: str = "describe" + ) -> List[Dict[str, Any]]: + """ + 批量分析多张图片 + + Args: + images: 图片路径列表 + mode: 分析模式 + + Returns: + 分析结果列表 + """ + results = [] + for image in images: + result = self.analyze(image, mode=mode) + result["image"] = image + results.append(result) + return results + + +def main(): + """命令行入口""" + import argparse + + parser = argparse.ArgumentParser(description='图生文分析工具(视觉识别)') + parser.add_argument('image', help='输入图片路径') + parser.add_argument('-m', '--mode', default='describe', + choices=['describe', 'ocr', 'chart', 'fashion', 'product', 'scene'], + help='分析模式') + parser.add_argument('-p', '--prompt', help='自定义分析提示词') + parser.add_argument('--max-tokens', type=int, default=2000, help='最大输出 token 数') + + args = parser.parse_args() + + analyzer = ImageToTextAnalyzer() + result = analyzer.analyze( + image=args.image, + prompt=args.prompt, + mode=args.mode, + max_tokens=args.max_tokens + ) + + if result["success"]: + print(f"\n=== 分析结果 ({result['mode']}) ===\n") + print(result["content"]) + print(f"\n=== Token 使用 ===") + print(f"输入: {result['usage'].get('prompt_tokens', 'N/A')}") + print(f"输出: {result['usage'].get('completion_tokens', 'N/A')}") + else: + print(f"分析失败: {result['error']}") + print(f"详情: {result.get('detail', 'N/A')}") + + +if __name__ == "__main__": + main() diff --git a/image-service/scripts/merge_long_image.py b/image-service/scripts/merge_long_image.py new file mode 100644 index 0000000..a5dead9 --- /dev/null +++ b/image-service/scripts/merge_long_image.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +""" +长图拼接脚本 (Merge Long Image) +将多张图片按顺序垂直拼接成一张微信长图 + +Author: 翟星人 +""" + +import argparse +import os +import glob as glob_module +from pathlib import Path +from typing import List, Optional, Dict, Any + +from PIL import Image +import numpy as np + + +class LongImageMerger: + """长图拼接器""" + + def __init__(self, target_width: int = 1080): + """ + 初始化拼接器 + + Args: + target_width: 目标宽度,默认1080(微信推荐宽度) + """ + self.target_width = target_width + + def _blend_images(self, img_top: Image.Image, img_bottom: Image.Image, blend_height: int) -> Image.Image: + """ + 在两张图的接缝处创建渐变融合过渡 + + Args: + img_top: 上方图片 + img_bottom: 下方图片 + blend_height: 融合区域高度(像素) + + Returns: + 融合后的下方图片(顶部已与上方图片底部融合) + """ + blend_height = min(blend_height, img_top.height // 4, img_bottom.height // 4) + + top_region = img_top.crop((0, img_top.height - blend_height, img_top.width, img_top.height)) + bottom_region = img_bottom.crop((0, 0, img_bottom.width, blend_height)) + + top_array = np.array(top_region, dtype=np.float32) + bottom_array = np.array(bottom_region, dtype=np.float32) + + alpha = np.linspace(1, 0, blend_height).reshape(-1, 1, 1) + + blended_array = top_array * alpha + bottom_array * (1 - alpha) + blended_array = np.clip(blended_array, 0, 255).astype(np.uint8) + + blended_region = Image.fromarray(blended_array) + + result = img_bottom.copy() + result.paste(blended_region, (0, 0)) + + return result + + def merge( + self, + image_paths: List[str], + output_path: str, + gap: int = 0, + background_color: str = "white", + blend: int = 0 + ) -> Dict[str, Any]: + """ + 拼接多张图片为长图 + + Args: + image_paths: 图片路径列表,按顺序拼接 + output_path: 输出文件路径 + gap: 图片之间的间隔像素,默认0 + background_color: 背景颜色,默认白色 + blend: 接缝融合过渡区域高度(像素),默认0不融合,推荐30-50 + + Returns: + 包含拼接结果的字典 + """ + if not image_paths: + return {"success": False, "error": "没有提供图片路径"} + + valid_paths = [] + for p in image_paths: + if os.path.exists(p): + valid_paths.append(p) + else: + print(f"警告: 文件不存在,跳过 - {p}") + + if not valid_paths: + return {"success": False, "error": "没有有效的图片文件"} + + try: + imgs = [Image.open(p) for p in valid_paths] + + resized_imgs = [] + for img in imgs: + if img.mode in ('RGBA', 'P'): + img = img.convert('RGB') + ratio = self.target_width / img.width + new_height = int(img.height * ratio) + resized = img.resize((self.target_width, new_height), Image.Resampling.LANCZOS) + resized_imgs.append(resized) + + if blend > 0 and len(resized_imgs) > 1: + for i in range(1, len(resized_imgs)): + resized_imgs[i] = self._blend_images(resized_imgs[i-1], resized_imgs[i], blend) + + total_height = sum(img.height for img in resized_imgs) + gap * (len(resized_imgs) - 1) + + long_image = Image.new('RGB', (self.target_width, total_height), background_color) + + y_offset = 0 + for img in resized_imgs: + long_image.paste(img, (0, y_offset)) + y_offset += img.height + gap + + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + long_image.save(output_path, quality=95) + + for img in imgs: + img.close() + for img in resized_imgs: + img.close() + + return { + "success": True, + "saved_path": output_path, + "width": self.target_width, + "height": total_height, + "image_count": len(resized_imgs) + } + + except Exception as e: + return {"success": False, "error": str(e)} + + def merge_from_pattern( + self, + pattern: str, + output_path: str, + sort_by: str = "name", + gap: int = 0, + background_color: str = "white", + blend: int = 0 + ) -> Dict[str, Any]: + """ + 通过 glob 模式匹配图片并拼接 + + Args: + pattern: glob 模式,如 "*.png" 或 "generated_*.png" + output_path: 输出文件路径 + sort_by: 排序方式 - "name"(文件名) / "time"(修改时间) / "none"(不排序) + gap: 图片间隔 + background_color: 背景颜色 + blend: 接缝融合过渡高度 + + Returns: + 包含拼接结果的字典 + """ + image_paths = glob_module.glob(pattern) + + if not image_paths: + return {"success": False, "error": f"没有找到匹配 '{pattern}' 的图片"} + + if sort_by == "name": + image_paths.sort() + elif sort_by == "time": + image_paths.sort(key=lambda x: os.path.getmtime(x)) + + print(f"找到 {len(image_paths)} 张图片:") + for i, p in enumerate(image_paths, 1): + print(f" {i}. {os.path.basename(p)}") + + return self.merge(image_paths, output_path, gap, background_color, blend) + + +def main(): + """命令行入口""" + parser = argparse.ArgumentParser( + description='长图拼接工具 - 将多张图片垂直拼接成微信长图', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例用法: + # 拼接指定图片 + python merge_long_image.py img1.png img2.png img3.png -o output.png + + # 使用通配符匹配 + python merge_long_image.py -p "generated_*.png" -o long_image.png + + # 指定宽度和间隔 + python merge_long_image.py -p "*.png" -o out.png -w 750 -g 20 + + # 按修改时间排序 + python merge_long_image.py -p "*.png" -o out.png --sort time + + # 启用接缝融合过渡(推荐40px) + python merge_long_image.py img1.png img2.png -o out.png --blend 40 + """ + ) + + parser.add_argument('images', nargs='*', help='要拼接的图片路径列表') + parser.add_argument('-p', '--pattern', help='glob 模式匹配图片,如 "*.png"') + parser.add_argument('-o', '--output', required=True, help='输出文件路径') + parser.add_argument('-w', '--width', type=int, default=1080, help='目标宽度,默认1080') + parser.add_argument('-g', '--gap', type=int, default=0, help='图片间隔像素,默认0') + parser.add_argument('--sort', choices=['name', 'time', 'none'], default='name', + help='排序方式:name(文件名)/time(修改时间)/none') + parser.add_argument('--bg', default='white', help='背景颜色,默认 white') + parser.add_argument('--blend', type=int, default=0, + help='接缝融合过渡高度(像素),推荐30-50,默认0不融合') + + args = parser.parse_args() + + if not args.images and not args.pattern: + parser.error("请提供图片路径列表或使用 -p 指定匹配模式") + + merger = LongImageMerger(target_width=args.width) + + if args.pattern: + result = merger.merge_from_pattern( + pattern=args.pattern, + output_path=args.output, + sort_by=args.sort, + gap=args.gap, + background_color=args.bg, + blend=args.blend + ) + else: + result = merger.merge( + image_paths=args.images, + output_path=args.output, + gap=args.gap, + background_color=args.bg, + blend=args.blend + ) + + if result["success"]: + print(f"\n拼接成功!") + print(f"输出文件: {result['saved_path']}") + print(f"尺寸: {result['width']} x {result['height']}") + print(f"共 {result['image_count']} 张图片") + else: + print(f"\n拼接失败: {result['error']}") + + +if __name__ == "__main__": + main() diff --git a/image-service/scripts/research_image.py b/image-service/scripts/research_image.py new file mode 100644 index 0000000..af5f053 --- /dev/null +++ b/image-service/scripts/research_image.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +调研报告专用信息图生成脚本 +预设手绘风格可视化模板,保持系列配图风格统一 + +Author: 翟星人 +""" + +import argparse +import subprocess +import sys +import os + +# 预设风格模板 - 手绘体可视化风格 +STYLE_TEMPLATES = { + "arch": { + "name": "架构图", + "prefix": "手绘风格技术架构信息图,简洁扁平设计,", + "suffix": "手绘线条感,柔和的科技蓝配色(#4A90D9),浅灰白色背景,模块化分层布局,圆角矩形框,手写体中文标签,简约图标,整体清新专业。", + "trigger": "核心架构、系统结构、技术栈、模块组成" + }, + "flow": { + "name": "流程图", + "prefix": "手绘风格流程信息图,简洁扁平设计,", + "suffix": "手绘线条和箭头,科技蓝(#4A90D9)主色调,浅绿色(#81C784)表示成功节点,浅橙色(#FFB74D)表示判断节点,浅灰白色背景,从上到下或从左到右布局,手写体中文标签,步骤清晰。", + "trigger": "流程、步骤、工作流、执行顺序" + }, + "compare": { + "name": "对比图", + "prefix": "手绘风格对比信息图,左右分栏设计,", + "suffix": "手绘线条感,左侧用柔和蓝色(#4A90D9),右侧用柔和橙色(#FF8A65),中间VS分隔,浅灰白色背景,手写体中文标签,对比项目清晰列出,简约图标点缀。", + "trigger": "对比、vs、区别、差异" + }, + "concept": { + "name": "概念图", + "prefix": "手绘风格概念信息图,中心发散设计,", + "suffix": "手绘线条感,中心主题用科技蓝(#4A90D9),周围要素用柔和的蓝紫渐变色系,浅灰白色背景,连接线条有手绘感,手写体中文标签,布局均衡美观。", + "trigger": "核心概念、要素组成、多个方面" + } +} + +# 基础路径 +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEXT_TO_IMAGE_SCRIPT = os.path.join(BASE_DIR, "scripts", "text_to_image.py") + + +def generate_image(style: str, title: str, content: str, output: str): + """ + 使用预设风格生成信息图 + + Args: + style: 风格类型 (arch/flow/compare/concept) + title: 图表标题 + content: 图表内容描述 + output: 输出路径 + """ + if style not in STYLE_TEMPLATES: + print(f"错误: 未知风格 '{style}'") + print(f"可用风格: {', '.join(STYLE_TEMPLATES.keys())}") + sys.exit(1) + + template = STYLE_TEMPLATES[style] + + # 组装完整提示词 + prompt = f"{template['prefix']}标题:{title},{content},{template['suffix']}" + + print(f"生成 {template['name']}: {title}") + print(f"风格: 手绘体可视化") + print(f"输出: {output}") + + # 调用 text_to_image.py + cmd = [ + sys.executable, + TEXT_TO_IMAGE_SCRIPT, + prompt, + "--output", output + ] + + result = subprocess.run(cmd, capture_output=False) + + if result.returncode != 0: + print(f"生成失败") + sys.exit(1) + + +def list_styles(): + """列出所有可用风格""" + print("可用风格模板(手绘体可视化):\n") + for key, template in STYLE_TEMPLATES.items(): + print(f" {key:10} - {template['name']}") + print(f" 触发场景: {template['trigger']}") + print() + + +def main(): + parser = argparse.ArgumentParser( + description="调研报告专用信息图生成(手绘风格)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + # 生成架构图 + python research_image.py -t arch -n "Ralph Loop 核心架构" -c "展示 Prompt、Agent、Stop Hook、Files 四个模块的循环关系" -o images/arch.png + + # 生成流程图 + python research_image.py -t flow -n "Stop Hook 工作流程" -c "Agent尝试退出、Hook触发、检查条件、允许或阻止退出" -o images/flow.png + + # 生成对比图 + python research_image.py -t compare -n "ReAct vs Ralph Loop" -c "左侧ReAct自我评估停止,右侧Ralph外部Hook控制" -o images/compare.png + + # 生成概念图 + python research_image.py -t concept -n "状态持久化" -c "中心是Agent,周围是progress.txt、prd.json、Git历史、代码文件四个要素" -o images/concept.png + + # 查看所有风格 + python research_image.py --list + """ + ) + + parser.add_argument("-t", "--type", choices=list(STYLE_TEMPLATES.keys()), + help="图解类型: arch(架构图), flow(流程图), compare(对比图), concept(概念图)") + parser.add_argument("-n", "--name", help="图表标题") + parser.add_argument("-c", "--content", help="图表内容描述") + parser.add_argument("-o", "--output", help="输出文件路径") + parser.add_argument("--list", action="store_true", help="列出所有可用风格") + + args = parser.parse_args() + + if args.list: + list_styles() + return + + if not all([args.type, args.name, args.content, args.output]): + parser.print_help() + print("\n错误: 必须提供 -t, -n, -c, -o 参数") + sys.exit(1) + + generate_image(args.type, args.name, args.content, args.output) + + +if __name__ == "__main__": + main() diff --git a/image-service/scripts/text_to_image.py b/image-service/scripts/text_to_image.py new file mode 100644 index 0000000..b6ff833 --- /dev/null +++ b/image-service/scripts/text_to_image.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +""" +文生图脚本 (Text-to-Image) +使用 Lyra Flash API 根据中文文本描述生成图片 +支持参考图风格生成 + +Author: 翟星人 +""" + +import httpx +import base64 +import json +import os +from typing import Dict, Any, Optional, Union +from pathlib import Path + +VALID_ASPECT_RATIOS = [ + "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9" +] + +VALID_SIZES = [ + "1024x1024", + "1536x1024", "1792x1024", "1344x768", "1248x832", "1184x864", "1152x896", "1536x672", + "1024x1536", "1024x1792", "768x1344", "832x1248", "864x1184", "896x1152" +] + +RATIO_TO_SIZE = { + "1:1": "1024x1024", + "2:3": "832x1248", + "3:2": "1248x832", + "3:4": "1024x1536", + "4:3": "1536x1024", + "4:5": "864x1184", + "5:4": "1184x864", + "9:16": "1024x1792", + "16:9": "1792x1024", + "21:9": "1536x672" +} + + +class TextToImageGenerator: + """文生图生成器""" + + def __init__(self, config: Optional[Dict[str, str]] = None): + """ + 初始化生成器 + + Args: + config: 配置字典,包含 api_key, base_url, model + 如果不传则从环境变量或配置文件读取 + """ + if config is None: + config = self._load_config() + + self.api_key = config.get('api_key') or config.get('IMAGE_API_KEY') + self.base_url = config.get('base_url') or config.get('IMAGE_API_BASE_URL') + self.model = config.get('model') or config.get('IMAGE_MODEL') or 'lyra-flash-9' + + if not self.api_key or not self.base_url: + raise ValueError("缺少必要的 API 配置:api_key 和 base_url") + + def _load_config(self) -> Dict[str, str]: + """从配置文件或环境变量加载配置""" + config = {} + + config_path = Path(__file__).parent.parent / 'config' / 'settings.json' + if config_path.exists(): + with open(config_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + api_config = settings.get('image_api', {}) + config['api_key'] = api_config.get('key') + config['base_url'] = api_config.get('base_url') + config['model'] = api_config.get('model') + + config['api_key'] = os.getenv('IMAGE_API_KEY', config.get('api_key')) + config['base_url'] = os.getenv('IMAGE_API_BASE_URL', config.get('base_url')) + config['model'] = os.getenv('IMAGE_MODEL', config.get('model')) + + return config + + @staticmethod + def image_to_base64(image_path: str, with_prefix: bool = True) -> str: + """将图片文件转换为 base64 编码""" + path = Path(image_path) + if not path.exists(): + raise FileNotFoundError(f"图片文件不存在: {image_path}") + + suffix = path.suffix.lower() + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp' + } + mime_type = mime_types.get(suffix, 'image/png') + + with open(image_path, 'rb') as f: + b64_str = base64.b64encode(f.read()).decode('utf-8') + + if with_prefix: + return f"data:{mime_type};base64,{b64_str}" + return b64_str + + def generate( + self, + prompt: str, + size: Optional[str] = None, + aspect_ratio: Optional[str] = None, + image_size: Optional[str] = None, + output_path: Optional[str] = None, + response_format: str = "b64_json", + ref_image: Optional[str] = None + ) -> Dict[str, Any]: + """ + 生成图片 + + Args: + prompt: 中文图像描述提示词 + size: 图片尺寸 (如 1792x1024),与 aspect_ratio 二选一 + aspect_ratio: 宽高比 (如 16:9, 3:4),推荐使用 + image_size: 分辨率 (1K/2K/4K),仅 gemini-3.0-pro-image-preview 支持 + output_path: 输出文件路径,如果提供则保存图片 + response_format: 响应格式,默认 b64_json + ref_image: 参考图片路径,用于风格参考 + + Returns: + 包含生成结果的字典 + """ + if ref_image: + return self._generate_with_reference( + prompt=prompt, + ref_image=ref_image, + aspect_ratio=aspect_ratio, + size=size, + output_path=output_path, + response_format=response_format + ) + + payload: Dict[str, Any] = { + "model": self.model, + "prompt": prompt, + "response_format": response_format + } + + # 确定尺寸:优先用 aspect_ratio 映射,其次用 size + if aspect_ratio: + payload["size"] = RATIO_TO_SIZE.get(aspect_ratio, "1024x1024") + elif size: + payload["size"] = size + else: + payload["size"] = "1792x1024" # 默认 16:9 + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + } + + try: + with httpx.Client(timeout=180.0) as client: + response = client.post( + f"{self.base_url}/images/generations", + headers=headers, + json=payload + ) + response.raise_for_status() + result = response.json() + + if output_path and result.get("data"): + b64_data = result["data"][0].get("b64_json") + if b64_data: + self._save_image(b64_data, output_path) + result["saved_path"] = output_path + + return { + "success": True, + "data": result, + "saved_path": output_path if output_path else None + } + + except httpx.HTTPStatusError as e: + return { + "success": False, + "error": f"HTTP 错误: {e.response.status_code}", + "detail": str(e) + } + except Exception as e: + return { + "success": False, + "error": "生成失败", + "detail": str(e) + } + + def _generate_with_reference( + self, + prompt: str, + ref_image: str, + aspect_ratio: Optional[str] = None, + size: Optional[str] = None, + output_path: Optional[str] = None, + response_format: str = "b64_json" + ) -> Dict[str, Any]: + """ + 参考图片风格生成新图 + + Args: + prompt: 新图内容描述 + ref_image: 参考图片路径 + aspect_ratio: 宽高比 + size: 尺寸 + output_path: 输出路径 + response_format: 响应格式 + """ + image_b64 = self.image_to_base64(ref_image) + + enhanced_prompt = f"参考这张图片的背景风格、配色方案和视觉设计,保持完全一致的风格,生成新内容:{prompt}" + + # 确定尺寸:优先用 aspect_ratio 映射,其次用 size + if size is None: + size = RATIO_TO_SIZE.get(aspect_ratio, "1024x1792") if aspect_ratio else "1024x1792" + + payload = { + "model": self.model, + "prompt": enhanced_prompt, + "image": image_b64, + "size": size, + "response_format": response_format + } + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + } + + try: + with httpx.Client(timeout=180.0) as client: + response = client.post( + f"{self.base_url}/images/edits", + headers=headers, + json=payload + ) + response.raise_for_status() + result = response.json() + + if output_path and result.get("data"): + b64_data = result["data"][0].get("b64_json") + if b64_data: + self._save_image(b64_data, output_path) + result["saved_path"] = output_path + + return { + "success": True, + "data": result, + "saved_path": output_path if output_path else None + } + + except httpx.HTTPStatusError as e: + return { + "success": False, + "error": f"HTTP 错误: {e.response.status_code}", + "detail": str(e) + } + except Exception as e: + return { + "success": False, + "error": "生成失败", + "detail": str(e) + } + + def _save_image(self, b64_data: str, output_path: str) -> None: + """保存 base64 图片到文件""" + image_data = base64.b64decode(b64_data) + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + with open(output_path, 'wb') as f: + f.write(image_data) + + +def main(): + """命令行入口""" + import argparse + import time + + parser = argparse.ArgumentParser( + description='文生图工具', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=f''' +尺寸参数说明: + -r/--ratio 推荐使用,支持: {", ".join(VALID_ASPECT_RATIOS)} + -s/--size 传统尺寸,支持: {", ".join(VALID_SIZES[:4])}... + --resolution 分辨率(1K/2K/4K),仅 gemini-3.0-pro-image-preview 支持 + --ref 参考图片路径,后续图片将参考首图风格生成 + +示例: + python text_to_image.py "描述" -r 3:4 # 竖版 3:4 + python text_to_image.py "描述" -r 9:16 -o out.png # 竖屏 9:16 + python text_to_image.py "描述" -s 1024x1792 # 传统尺寸 + + # 长图场景:首图定调,后续参考首图风格 + python text_to_image.py "首屏内容" -r 3:4 -o 01.png + python text_to_image.py "第二屏内容" -r 3:4 --ref 01.png -o 02.png +''' + ) + parser.add_argument('prompt', help='中文图像描述提示词') + parser.add_argument('-o', '--output', help='输出文件路径(默认保存到当前目录)') + parser.add_argument('-r', '--ratio', help=f'宽高比,推荐使用。可选: {", ".join(VALID_ASPECT_RATIOS)}') + parser.add_argument('-s', '--size', help='图片尺寸 (如 1792x1024)') + parser.add_argument('--resolution', help='分辨率 (1K/2K/4K),仅部分模型支持') + parser.add_argument('--ref', help='参考图片路径,用于风格参考(长图场景)') + + args = parser.parse_args() + + if args.ratio and args.ratio not in VALID_ASPECT_RATIOS: + print(f"错误: 不支持的宽高比 '{args.ratio}'") + print(f"支持的宽高比: {', '.join(VALID_ASPECT_RATIOS)}") + return + + if args.size and args.size not in VALID_SIZES: + print(f"警告: 尺寸 '{args.size}' 可能不被支持") + print(f"推荐使用 -r/--ratio 参数指定宽高比") + + if args.ref and not os.path.exists(args.ref): + print(f"错误: 参考图片不存在: {args.ref}") + return + + output_path = args.output + if not output_path: + timestamp = time.strftime("%Y%m%d_%H%M%S") + output_path = f"generated_{timestamp}.png" + + generator = TextToImageGenerator() + result = generator.generate( + prompt=args.prompt, + size=args.size, + aspect_ratio=args.ratio, + image_size=args.resolution, + output_path=output_path, + ref_image=args.ref + ) + + if result["success"]: + print(f"生成成功!") + if result.get("saved_path"): + print(f"图片已保存到: {result['saved_path']}") + else: + print(f"生成失败: {result['error']}") + print(f"详情: {result.get('detail', 'N/A')}") + + +if __name__ == "__main__": + main() diff --git a/log-analyzer/README.md b/log-analyzer/README.md new file mode 100644 index 0000000..11cb05a --- /dev/null +++ b/log-analyzer/README.md @@ -0,0 +1,20 @@ +# Log Analyzer + +智能日志分析器,支持多种日志类型。 + +## 依赖 + +无需额外安装,纯 Python 标准库实现。 + +## 功能 + +- 自动识别日志类型(Java App / MySQL Binlog / Nginx / Trace / Alert) +- 提取 20+ 种实体(IP、thread_id、user_id、表名等) +- 敏感操作检测、异常洞察 +- 支持 100M+ 大文件流式处理 + +## 使用 + +```bash +python scripts/preprocess.py <日志文件> -o ./log_analysis +``` diff --git a/log-analyzer/SKILL.md b/log-analyzer/SKILL.md new file mode 100644 index 0000000..d32f480 --- /dev/null +++ b/log-analyzer/SKILL.md @@ -0,0 +1,109 @@ +--- +name: log-analyzer +description: 全维度日志分析技能。自动识别日志类型(Java应用/MySQL Binlog/Nginx/Trace/告警),提取关键实体(IP、thread_id、trace_id、用户、表名等),进行根因定位、告警分析、异常洞察。支持100M+大文件。触发词:分析日志、日志排查、根因定位、告警分析、异常分析。 +--- + +# 日志分析器 + +基于 RAPHL(Recursive Analysis Pattern for Hierarchical Logs)的全维度智能日志分析技能。流式处理,内存占用低,100M+ 日志秒级分析。 + +## 核心能力 + +| 能力 | 说明 | +|------|------| +| 自动识别 | 自动识别日志类型:Java App / MySQL Binlog / Nginx / Trace / Alert | +| 实体提取 | IP、thread_id、trace_id、user_id、session_id、bucket、URL、表名等 20+ 种 | +| 操作分析 | DELETE/UPDATE/INSERT/DROP 等敏感操作检测 | +| 关联分析 | 时间线、因果链、操作链构建 | +| 智能洞察 | 自动生成分析结论、证据、建议 | + +## 支持的日志类型 + +| 类型 | 识别特征 | 提取内容 | +|------|----------|----------| +| **Java App** | ERROR/WARN + 堆栈 | 异常类型、堆栈、logger、时间 | +| **MySQL Binlog** | server id、GTID、Table_map | 表操作、thread_id、server_id、数据变更 | +| **Nginx Access** | IP + HTTP 方法 + 状态码 | 请求IP、URL、状态码、耗时 | +| **Trace** | trace_id、span_id | 链路追踪、调用关系、耗时 | +| **Alert** | CRITICAL/告警 | 告警级别、来源、消息 | +| **General** | 通用 | 时间、IP、关键词 | + +## 使用方法 + +```bash +python .opencode/skills/log-analyzer/scripts/preprocess.py <日志文件> -o ./log_analysis +``` + +## 输出文件 + +| 文件 | 内容 | 用途 | +|------|------|------| +| `summary.md` | 完整分析报告 | **优先阅读** | +| `entities.md` | 实体详情(IP、用户、表名等) | 追溯操作来源 | +| `operations.md` | 操作详情 | 查看具体操作 | +| `insights.md` | 智能洞察 | 问题定位和建议 | +| `analysis.json` | 结构化数据 | 程序处理 | + +## 实体提取清单 + +### 网络/连接类 +- IP 地址、IP:Port、URL、MAC 地址 + +### 追踪/会话类 +- trace_id、span_id、request_id、session_id、thread_id + +### 用户/权限类 +- user_id、ak(access_key)、bucket + +### 数据库类 +- database.table、server_id + +### 性能/状态类 +- duration(耗时)、http_status、error_code + +## 敏感操作检测 + +| 类型 | 检测模式 | 风险级别 | +|------|----------|----------| +| 数据删除 | DELETE, DROP, TRUNCATE | HIGH | +| 数据修改 | UPDATE, ALTER, MODIFY | MEDIUM | +| 权限变更 | GRANT, REVOKE, chmod | HIGH | +| 认证操作 | LOGIN, LOGOUT, AUTH | MEDIUM | + +## 智能洞察类型 + +| 类型 | 说明 | +|------|------| +| security | 大批量删除/修改、权限变更 | +| anomaly | 高频 IP、异常时间段操作 | +| error | 严重异常、错误聚类 | +| audit | 操作来源、用户行为 | + +## 分析流程 + +``` +Phase 1: 日志类型识别(采样前100行) + ↓ +Phase 2: 全量扫描提取(流式处理) + ↓ +Phase 3: 关联分析(时间排序、聚合统计) + ↓ +Phase 4: 智能洞察(异常检测、生成结论) + ↓ +Phase 5: 生成报告(Markdown + JSON) +``` + +## 技术特点 + +| 特点 | 说明 | +|------|------| +| 流式处理 | 逐行读取,100M 文件只占几 MB 内存 | +| 正则预编译 | 20+ 种实体模式预编译,匹配快 | +| 一次遍历 | 提取 + 统计 + 分类一次完成 | +| 类型适配 | 不同日志类型用专用解析器 | + +## 注意事项 + +1. **Binlog 不记录客户端 IP**:只有 server_id 和 thread_id,需结合 general_log 确认操作者 +2. **敏感信息脱敏**:报告中注意不要暴露密码、密钥 +3. **结合多源日志**:binlog + 应用日志 + 审计日志 才能完整还原 diff --git a/log-analyzer/scripts/preprocess.py b/log-analyzer/scripts/preprocess.py new file mode 100644 index 0000000..61d5fbc --- /dev/null +++ b/log-analyzer/scripts/preprocess.py @@ -0,0 +1,849 @@ +#!/usr/bin/env python3 +""" +RAPHL 日志分析器 - 全维度智能分析 + +Author: 翟星人 +Created: 2026-01-18 + +支持多种日志类型的自动识别和深度分析: +- Java/应用日志:异常堆栈、ERROR/WARN +- MySQL Binlog:DDL/DML 操作、表变更、事务分析 +- 审计日志:用户操作、权限变更 +- 告警日志:告警级别、告警源 +- Trace 日志:链路追踪、调用关系 +- 通用日志:IP、时间、关键词提取 + +核心能力: +1. 自动识别日志类型 +2. 提取关键实体(IP、用户、表名、thread_id 等) +3. 时间线分析 +4. 关联分析(因果链、操作链) +5. 智能洞察和异常检测 +""" + +import argparse +import re +import hashlib +import json +from pathlib import Path +from collections import defaultdict, Counter +from datetime import datetime +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + + +class LogType(Enum): + JAVA_APP = "java_app" + MYSQL_BINLOG = "mysql_binlog" + NGINX_ACCESS = "nginx_access" + AUDIT = "audit" + TRACE = "trace" + ALERT = "alert" + GENERAL = "general" + + +@dataclass +class Entity: + """提取的实体""" + type: str # ip, user, table, thread_id, trace_id, bucket, etc. + value: str + line_num: int + context: str = "" + + +@dataclass +class Operation: + """操作记录""" + line_num: int + time: str + op_type: str # DELETE, UPDATE, INSERT, DROP, SELECT, API_CALL, etc. + target: str # 表名、接口名等 + detail: str + entities: list = field(default_factory=list) # 关联的实体 + raw_content: str = "" + + +@dataclass +class Alert: + """告警记录""" + line_num: int + time: str + level: str # CRITICAL, WARNING, INFO + source: str + message: str + entities: list = field(default_factory=list) + + +@dataclass +class Trace: + """链路追踪""" + trace_id: str + span_id: str + parent_id: str + service: str + operation: str + duration: float + status: str + line_num: int + + +@dataclass +class Insight: + """分析洞察""" + category: str # security, performance, error, anomaly + severity: str # critical, high, medium, low + title: str + description: str + evidence: list = field(default_factory=list) + recommendation: str = "" + + +class SmartLogAnalyzer: + """智能日志分析器 - 全维度感知""" + + # ============ 实体提取模式 ============ + ENTITY_PATTERNS = { + 'ip': re.compile(r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b'), + 'ip_port': re.compile(r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+)\b'), + 'mac': re.compile(r'\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b'), + 'email': re.compile(r'\b[\w.-]+@[\w.-]+\.\w+\b'), + 'url': re.compile(r'https?://[^\s<>"\']+'), + 'uuid': re.compile(r'\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', re.I), + 'trace_id': re.compile(r'\b(?:trace[_-]?id|traceid|x-trace-id)[=:\s]*([a-zA-Z0-9_-]{16,64})\b', re.I), + 'span_id': re.compile(r'\b(?:span[_-]?id|spanid)[=:\s]*([a-zA-Z0-9_-]{8,32})\b', re.I), + 'request_id': re.compile(r'\b(?:request[_-]?id|req[_-]?id)[=:\s]*([a-zA-Z0-9_-]{8,64})\b', re.I), + 'user_id': re.compile(r'\b(?:user[_-]?id|uid|userid)[=:\s]*([a-zA-Z0-9_-]+)\b', re.I), + 'thread_id': re.compile(r'\bthread[_-]?id[=:\s]*(\d+)\b', re.I), + 'session_id': re.compile(r'\b(?:session[_-]?id|sid)[=:\s]*([a-zA-Z0-9_-]+)\b', re.I), + 'ak': re.compile(r'\b(?:ak|access[_-]?key)[=:\s]*([a-zA-Z0-9]{16,64})\b', re.I), + 'bucket': re.compile(r'\bbucket[=:\s]*([a-zA-Z0-9_.-]+)\b', re.I), + 'database': re.compile(r'`([a-zA-Z_][a-zA-Z0-9_]*)`\.`([a-zA-Z_][a-zA-Z0-9_]*)`'), + 'duration_ms': re.compile(r'\b(?:duration|cost|elapsed|time)[=:\s]*(\d+(?:\.\d+)?)\s*(?:ms|毫秒)\b', re.I), + 'duration_s': re.compile(r'\b(?:duration|cost|elapsed|time)[=:\s]*(\d+(?:\.\d+)?)\s*(?:s|秒)\b', re.I), + 'error_code': re.compile(r'\b(?:error[_-]?code|errno|code)[=:\s]*([A-Z0-9_-]+)\b', re.I), + 'http_status': re.compile(r'\b(?:status|http[_-]?code)[=:\s]*([1-5]\d{2})\b', re.I), + } + + # ============ 时间格式 ============ + TIME_PATTERNS = [ + (re.compile(r'(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{3})?)'), '%Y-%m-%d %H:%M:%S'), + (re.compile(r'(\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2})'), '%d/%b/%Y:%H:%M:%S'), + (re.compile(r'#(\d{6} \d{2}:\d{2}:\d{2})'), '%y%m%d %H:%M:%S'), # MySQL binlog + (re.compile(r'\[(\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2})'), '%d/%b/%Y:%H:%M:%S'), # Nginx + ] + + # ============ 日志类型识别 ============ + LOG_TYPE_SIGNATURES = { + LogType.MYSQL_BINLOG: [ + re.compile(r'server id \d+.*end_log_pos'), + re.compile(r'GTID.*last_committed'), + re.compile(r'Table_map:.*mapped to number'), + re.compile(r'(Delete_rows|Update_rows|Write_rows|Query).*table id'), + ], + LogType.JAVA_APP: [ + re.compile(r'(ERROR|WARN|INFO|DEBUG)\s+[\w.]+\s+-'), + re.compile(r'^\s+at\s+[\w.$]+\([\w.]+:\d+\)'), + re.compile(r'Exception|Error|Throwable'), + ], + LogType.NGINX_ACCESS: [ + re.compile(r'\d+\.\d+\.\d+\.\d+\s+-\s+-\s+\['), + re.compile(r'"(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+'), + ], + LogType.TRACE: [ + re.compile(r'trace[_-]?id', re.I), + re.compile(r'span[_-]?id', re.I), + re.compile(r'parent[_-]?id', re.I), + ], + LogType.ALERT: [ + re.compile(r'(CRITICAL|ALERT|EMERGENCY)', re.I), + re.compile(r'告警|报警|alarm', re.I), + ], + } + + # ============ MySQL Binlog 分析 ============ + BINLOG_PATTERNS = { + 'gtid': re.compile(r"GTID_NEXT=\s*'([^']+)'"), + 'thread_id': re.compile(r'thread_id=(\d+)'), + 'server_id': re.compile(r'server id (\d+)'), + 'table_map': re.compile(r'Table_map:\s*`(\w+)`\.`(\w+)`\s*mapped to number (\d+)'), + 'delete_rows': re.compile(r'Delete_rows:\s*table id (\d+)'), + 'update_rows': re.compile(r'Update_rows:\s*table id (\d+)'), + 'write_rows': re.compile(r'Write_rows:\s*table id (\d+)'), + 'query': re.compile(r'Query\s+thread_id=(\d+)'), + 'xid': re.compile(r'Xid\s*=\s*(\d+)'), + 'delete_from': re.compile(r'###\s*DELETE FROM\s*`(\w+)`\.`(\w+)`'), + 'update': re.compile(r'###\s*UPDATE\s*`(\w+)`\.`(\w+)`'), + 'insert': re.compile(r'###\s*INSERT INTO\s*`(\w+)`\.`(\w+)`'), + 'time': re.compile(r'#(\d{6} \d{2}:\d{2}:\d{2})'), + } + + # ============ 告警级别 ============ + ALERT_PATTERNS = { + 'CRITICAL': re.compile(r'\b(CRITICAL|FATAL|EMERGENCY|P0|严重|致命)\b', re.I), + 'HIGH': re.compile(r'\b(ERROR|ALERT|P1|高|错误)\b', re.I), + 'MEDIUM': re.compile(r'\b(WARN|WARNING|P2|中|警告)\b', re.I), + 'LOW': re.compile(r'\b(INFO|NOTICE|P3|低|提示)\b', re.I), + } + + # ============ 敏感操作 ============ + SENSITIVE_OPS = { + 'data_delete': re.compile(r'\b(DELETE|DROP|TRUNCATE|REMOVE)\b', re.I), + 'data_modify': re.compile(r'\b(UPDATE|ALTER|MODIFY|REPLACE)\b', re.I), + 'permission': re.compile(r'\b(GRANT|REVOKE|chmod|chown|赋权|权限)\b', re.I), + 'auth': re.compile(r'\b(LOGIN|LOGOUT|AUTH|认证|登录|登出)\b', re.I), + 'config_change': re.compile(r'\b(SET|CONFIG|配置变更)\b', re.I), + } + + def __init__(self, input_path: str, output_dir: str): + self.input_path = Path(input_path) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + # 分析结果 + self.log_type: LogType = LogType.GENERAL + self.total_lines = 0 + self.file_size_mb = 0 + self.time_range = {'start': '', 'end': ''} + + # 提取的数据 + self.entities: dict[str, list[Entity]] = defaultdict(list) + self.operations: list[Operation] = [] + self.alerts: list[Alert] = [] + self.traces: list[Trace] = [] + self.insights: list[Insight] = [] + + # 统计数据 + self.stats = defaultdict(Counter) + + # Binlog 特有 + self.table_map: dict[str, tuple[str, str]] = {} # table_id -> (db, table) + self.current_thread_id = "" + self.current_server_id = "" + self.current_time = "" + + def run(self) -> dict: + print(f"\n{'='*60}") + print(f"RAPHL 智能日志分析器") + print(f"{'='*60}") + + self.file_size_mb = self.input_path.stat().st_size / (1024 * 1024) + print(f"文件: {self.input_path.name}") + print(f"大小: {self.file_size_mb:.2f} MB") + + # Phase 1: 识别日志类型 + print(f"\n{'─'*40}") + print("Phase 1: 日志类型识别") + print(f"{'─'*40}") + self._detect_log_type() + print(f" ✓ 类型: {self.log_type.value}") + + # Phase 2: 全量扫描提取 + print(f"\n{'─'*40}") + print("Phase 2: 全量扫描提取") + print(f"{'─'*40}") + self._full_scan() + + # Phase 3: 关联分析 + print(f"\n{'─'*40}") + print("Phase 3: 关联分析") + print(f"{'─'*40}") + self._correlate() + + # Phase 4: 生成洞察 + print(f"\n{'─'*40}") + print("Phase 4: 智能洞察") + print(f"{'─'*40}") + self._generate_insights() + + # Phase 5: 生成报告 + print(f"\n{'─'*40}") + print("Phase 5: 生成报告") + print(f"{'─'*40}") + self._generate_reports() + + print(f"\n{'='*60}") + print("分析完成") + print(f"{'='*60}") + + return self._get_summary() + + def _detect_log_type(self): + """识别日志类型""" + sample_lines = [] + with open(self.input_path, 'r', encoding='utf-8', errors='ignore') as f: + for i, line in enumerate(f): + sample_lines.append(line) + if i >= 100: + break + + sample_text = '\n'.join(sample_lines) + + scores = {t: 0 for t in LogType} + for log_type, patterns in self.LOG_TYPE_SIGNATURES.items(): + for pattern in patterns: + matches = pattern.findall(sample_text) + scores[log_type] += len(matches) + + best_type = max(scores.keys(), key=lambda x: scores[x]) + if scores[best_type] > 0: + self.log_type = best_type + else: + self.log_type = LogType.GENERAL + + def _full_scan(self): + """全量扫描提取""" + if self.log_type == LogType.MYSQL_BINLOG: + self._scan_binlog() + elif self.log_type == LogType.JAVA_APP: + self._scan_java_app() + else: + self._scan_general() + + print(f" ✓ 总行数: {self.total_lines:,}") + print(f" ✓ 时间范围: {self.time_range['start']} ~ {self.time_range['end']}") + + # 实体统计 + for entity_type, entities in self.entities.items(): + unique = len(set(e.value for e in entities)) + print(f" ✓ {entity_type}: {unique} 个唯一值, {len(entities)} 次出现") + + if self.operations: + print(f" ✓ 操作记录: {len(self.operations)} 条") + if self.alerts: + print(f" ✓ 告警记录: {len(self.alerts)} 条") + + def _scan_binlog(self): + """扫描 MySQL Binlog""" + current_op = None + + with open(self.input_path, 'r', encoding='utf-8', errors='ignore') as f: + for line_num, line in enumerate(f, 1): + self.total_lines += 1 + + # 提取时间 + time_match = self.BINLOG_PATTERNS['time'].search(line) + if time_match: + self.current_time = time_match.group(1) + self._update_time_range(self.current_time) + + # 提取 server_id + server_match = self.BINLOG_PATTERNS['server_id'].search(line) + if server_match: + self.current_server_id = server_match.group(1) + self._add_entity('server_id', self.current_server_id, line_num, line) + + # 提取 thread_id + thread_match = self.BINLOG_PATTERNS['thread_id'].search(line) + if thread_match: + self.current_thread_id = thread_match.group(1) + self._add_entity('thread_id', self.current_thread_id, line_num, line) + + # 提取 table_map + table_match = self.BINLOG_PATTERNS['table_map'].search(line) + if table_match: + db, table, table_id = table_match.groups() + self.table_map[table_id] = (db, table) + self._add_entity('database', f"{db}.{table}", line_num, line) + + # 识别操作类型 + for op_name, pattern in [ + ('DELETE', self.BINLOG_PATTERNS['delete_from']), + ('UPDATE', self.BINLOG_PATTERNS['update']), + ('INSERT', self.BINLOG_PATTERNS['insert']), + ]: + match = pattern.search(line) + if match: + db, table = match.groups() + self.stats['operations'][op_name] += 1 + self.stats['tables'][f"{db}.{table}"] += 1 + + if current_op is None or current_op.target != f"{db}.{table}": + if current_op: + self.operations.append(current_op) + current_op = Operation( + line_num=line_num, + time=self.current_time, + op_type=op_name, + target=f"{db}.{table}", + detail="", + entities=[ + Entity('thread_id', self.current_thread_id, line_num), + Entity('server_id', self.current_server_id, line_num), + ], + raw_content=line + ) + + # 提取行内实体(IP、用户等) + self._extract_entities(line, line_num) + + if line_num % 50000 == 0: + print(f" 已处理 {line_num:,} 行...") + + if current_op: + self.operations.append(current_op) + + def _scan_java_app(self): + """扫描 Java 应用日志""" + current_exception = None + context_buffer = [] + + error_pattern = re.compile( + r'^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{3})?)\s+' + r'(FATAL|ERROR|WARN|WARNING|INFO|DEBUG)\s+' + r'([\w.]+)\s+-\s+(.+)$' + ) + stack_pattern = re.compile(r'^\s+at\s+') + exception_pattern = re.compile(r'^([a-zA-Z_$][\w.$]*(?:Exception|Error|Throwable)):\s*(.*)$') + + with open(self.input_path, 'r', encoding='utf-8', errors='ignore') as f: + for line_num, line in enumerate(f, 1): + self.total_lines += 1 + line = line.rstrip() + + # 提取实体 + self._extract_entities(line, line_num) + + error_match = error_pattern.match(line) + if error_match: + time_str, level, logger, message = error_match.groups() + self._update_time_range(time_str) + + if level in ('ERROR', 'FATAL', 'WARN', 'WARNING'): + if current_exception: + self._finalize_exception(current_exception) + + current_exception = { + 'line_num': line_num, + 'time': time_str, + 'level': level, + 'logger': logger, + 'message': message, + 'stack': [], + 'context': list(context_buffer), + 'entities': [], + } + context_buffer.clear() + elif current_exception: + if stack_pattern.match(line) or exception_pattern.match(line): + current_exception['stack'].append(line) + elif line.startswith('Caused by:'): + current_exception['stack'].append(line) + else: + self._finalize_exception(current_exception) + current_exception = None + context_buffer.append(line) + else: + context_buffer.append(line) + if len(context_buffer) > 5: + context_buffer.pop(0) + + if line_num % 50000 == 0: + print(f" 已处理 {line_num:,} 行...") + + if current_exception: + self._finalize_exception(current_exception) + + def _finalize_exception(self, exc: dict): + """完成异常记录""" + level_map = {'FATAL': 'CRITICAL', 'ERROR': 'HIGH', 'WARN': 'MEDIUM', 'WARNING': 'MEDIUM'} + + self.alerts.append(Alert( + line_num=exc['line_num'], + time=exc['time'], + level=level_map.get(exc['level'], 'LOW'), + source=exc['logger'], + message=exc['message'], + entities=exc.get('entities', []) + )) + + if exc['stack']: + self.stats['exceptions'][exc['stack'][0].split(':')[0] if ':' in exc['stack'][0] else exc['level']] += 1 + + def _scan_general(self): + """通用日志扫描""" + with open(self.input_path, 'r', encoding='utf-8', errors='ignore') as f: + for line_num, line in enumerate(f, 1): + self.total_lines += 1 + + # 提取时间 + for pattern, fmt in self.TIME_PATTERNS: + match = pattern.search(line) + if match: + self._update_time_range(match.group(1)) + break + + # 提取实体 + self._extract_entities(line, line_num) + + # 识别告警 + for level, pattern in self.ALERT_PATTERNS.items(): + if pattern.search(line): + self.stats['alert_levels'][level] += 1 + break + + # 识别敏感操作 + for op_type, pattern in self.SENSITIVE_OPS.items(): + if pattern.search(line): + self.stats['sensitive_ops'][op_type] += 1 + + if line_num % 50000 == 0: + print(f" 已处理 {line_num:,} 行...") + + def _extract_entities(self, line: str, line_num: int): + """提取行内实体""" + for entity_type, pattern in self.ENTITY_PATTERNS.items(): + for match in pattern.finditer(line): + value = match.group(1) if match.lastindex else match.group(0) + self._add_entity(entity_type, value, line_num, line[:200]) + + def _add_entity(self, entity_type: str, value: str, line_num: int, context: str = ""): + """添加实体""" + # 过滤无效值 + if entity_type == 'ip' and value in ('0.0.0.0', '127.0.0.1', '255.255.255.255'): + return + if entity_type == 'duration_ms' and float(value) == 0: + return + + self.entities[entity_type].append(Entity( + type=entity_type, + value=value, + line_num=line_num, + context=context + )) + self.stats[f'{entity_type}_count'][value] += 1 + + def _update_time_range(self, time_str: str): + """更新时间范围""" + if not self.time_range['start'] or time_str < self.time_range['start']: + self.time_range['start'] = time_str + if not self.time_range['end'] or time_str > self.time_range['end']: + self.time_range['end'] = time_str + + def _correlate(self): + """关联分析""" + # 操作按时间排序 + if self.operations: + self.operations.sort(key=lambda x: x.time) + print(f" ✓ 操作时间线: {len(self.operations)} 条") + + # 聚合相同操作 + if self.log_type == LogType.MYSQL_BINLOG: + op_summary: dict[str, dict] = {} + for op in self.operations: + key = op.op_type + if key not in op_summary: + op_summary[key] = {'count': 0, 'tables': Counter(), 'thread_ids': set()} + op_summary[key]['count'] += 1 + op_summary[key]['tables'][op.target] += 1 + for e in op.entities: + if e.type == 'thread_id': + op_summary[key]['thread_ids'].add(e.value) + + for op_type, data in op_summary.items(): + tables_count = len(data['tables']) + thread_count = len(data['thread_ids']) + print(f" ✓ {op_type}: {data['count']} 次, 涉及 {tables_count} 个表, {thread_count} 个 thread_id") + + # IP 活动分析 + if 'ip' in self.entities: + ip_activity = Counter(e.value for e in self.entities['ip']) + top_ips = ip_activity.most_common(5) + if top_ips: + print(f" ✓ Top IP:") + for ip, count in top_ips: + print(f" {ip}: {count} 次") + + def _generate_insights(self): + """生成智能洞察""" + + # Binlog 洞察 + if self.log_type == LogType.MYSQL_BINLOG: + # 大批量删除检测 + delete_count = self.stats['operations'].get('DELETE', 0) + if delete_count > 100: + tables = self.stats['tables'].most_common(5) + thread_ids = list(set(e.value for e in self.entities.get('thread_id', []))) + server_ids = list(set(e.value for e in self.entities.get('server_id', []))) + + self.insights.append(Insight( + category='security', + severity='high', + title=f'大批量删除操作检测', + description=f'检测到 {delete_count} 条 DELETE 操作', + evidence=[ + f"时间范围: {self.time_range['start']} ~ {self.time_range['end']}", + f"涉及表: {', '.join(f'{t[0]}({t[1]}次)' for t in tables)}", + f"Server ID: {', '.join(server_ids)}", + f"Thread ID: {', '.join(thread_ids[:5])}{'...' if len(thread_ids) > 5 else ''}", + ], + recommendation='确认操作来源:1. 根据 thread_id 查询应用连接 2. 检查对应时间段的应用日志 3. 确认是否为正常业务行为' + )) + + # 操作来源分析 + if self.entities.get('server_id'): + unique_servers = set(e.value for e in self.entities['server_id']) + if len(unique_servers) == 1: + server_id = list(unique_servers)[0] + self.insights.append(Insight( + category='audit', + severity='medium', + title='操作来源确认', + description=f'所有操作来自同一数据库实例 server_id={server_id}', + evidence=[ + f"Server ID: {server_id}", + f"这是数据库主库的标识,不是客户端 IP", + f"Binlog 不记录客户端 IP,需查 general_log 或审计日志", + ], + recommendation='如需确认操作者 IP,请检查:1. MySQL general_log 2. 审计插件日志 3. 应用服务连接日志' + )) + + # 异常洞察 + if self.alerts: + critical_count = sum(1 for a in self.alerts if a.level == 'CRITICAL') + if critical_count > 0: + self.insights.append(Insight( + category='error', + severity='critical', + title=f'严重异常检测', + description=f'检测到 {critical_count} 个严重级别异常', + evidence=[f"L{a.line_num}: {a.message[:100]}" for a in self.alerts if a.level == 'CRITICAL'][:5], + recommendation='立即检查相关服务状态' + )) + + # IP 异常检测 + if 'ip' in self.entities: + ip_counter = Counter(e.value for e in self.entities['ip']) + for ip, count in ip_counter.most_common(3): + if count > 100: + self.insights.append(Insight( + category='anomaly', + severity='medium', + title=f'高频 IP 活动', + description=f'IP {ip} 出现 {count} 次', + evidence=[e.context[:100] for e in self.entities['ip'] if e.value == ip][:3], + recommendation='确认该 IP 的活动是否正常' + )) + + print(f" ✓ 生成 {len(self.insights)} 条洞察") + for insight in self.insights: + print(f" [{insight.severity.upper()}] {insight.title}") + + def _generate_reports(self): + """生成报告""" + self._write_summary() + self._write_entities() + self._write_operations() + self._write_insights() + self._write_json() + + print(f"\n输出文件:") + for f in sorted(self.output_dir.iterdir()): + size = f.stat().st_size + print(f" - {f.name} ({size/1024:.1f} KB)") + + def _write_summary(self): + """写入摘要报告""" + path = self.output_dir / "summary.md" + with open(path, 'w', encoding='utf-8') as f: + f.write(f"# 日志分析报告\n\n") + + f.write(f"## 概览\n\n") + f.write(f"| 项目 | 内容 |\n|------|------|\n") + f.write(f"| 文件 | {self.input_path.name} |\n") + f.write(f"| 大小 | {self.file_size_mb:.2f} MB |\n") + f.write(f"| 类型 | {self.log_type.value} |\n") + f.write(f"| 总行数 | {self.total_lines:,} |\n") + f.write(f"| 时间范围 | {self.time_range['start']} ~ {self.time_range['end']} |\n\n") + + # 实体统计 + if self.entities: + f.write(f"## 实体统计\n\n") + f.write(f"| 类型 | 唯一值 | 出现次数 | Top 值 |\n|------|--------|----------|--------|\n") + for entity_type, entities in sorted(self.entities.items()): + counter = Counter(e.value for e in entities) + unique = len(counter) + total = len(entities) + top = counter.most_common(1)[0] if counter else ('', 0) + f.write(f"| {entity_type} | {unique} | {total} | {top[0][:30]}({top[1]}) |\n") + f.write(f"\n") + + # 操作统计 + if self.stats['operations']: + f.write(f"## 操作统计\n\n") + f.write(f"| 操作类型 | 次数 |\n|----------|------|\n") + for op, count in self.stats['operations'].most_common(): + f.write(f"| {op} | {count:,} |\n") + f.write(f"\n") + + if self.stats['tables']: + f.write(f"## 表操作统计\n\n") + f.write(f"| 表名 | 操作次数 |\n|------|----------|\n") + for table, count in self.stats['tables'].most_common(10): + f.write(f"| {table} | {count:,} |\n") + f.write(f"\n") + + # 洞察 + if self.insights: + f.write(f"## 分析洞察\n\n") + for i, insight in enumerate(self.insights, 1): + f.write(f"### {i}. [{insight.severity.upper()}] {insight.title}\n\n") + f.write(f"{insight.description}\n\n") + if insight.evidence: + f.write(f"**证据:**\n") + for e in insight.evidence: + f.write(f"- {e}\n") + f.write(f"\n") + if insight.recommendation: + f.write(f"**建议:** {insight.recommendation}\n\n") + f.write(f"---\n\n") + + def _write_entities(self): + """写入实体详情""" + path = self.output_dir / "entities.md" + with open(path, 'w', encoding='utf-8') as f: + f.write(f"# 实体详情\n\n") + + for entity_type, entities in sorted(self.entities.items()): + counter = Counter(e.value for e in entities) + f.write(f"## {entity_type} ({len(counter)} 个唯一值)\n\n") + f.write(f"| 值 | 出现次数 | 首次行号 |\n|-----|----------|----------|\n") + + first_occurrence = {} + for e in entities: + if e.value not in first_occurrence: + first_occurrence[e.value] = e.line_num + + for value, count in counter.most_common(50): + f.write(f"| {value[:50]} | {count} | {first_occurrence[value]} |\n") + f.write(f"\n") + + def _write_operations(self): + """写入操作详情""" + if not self.operations: + return + + path = self.output_dir / "operations.md" + with open(path, 'w', encoding='utf-8') as f: + f.write(f"# 操作详情\n\n") + f.write(f"共 {len(self.operations)} 条操作记录\n\n") + + # 按表分组 + by_table = defaultdict(list) + for op in self.operations: + by_table[op.target].append(op) + + for table, ops in sorted(by_table.items(), key=lambda x: len(x[1]), reverse=True): + f.write(f"## {table} ({len(ops)} 次操作)\n\n") + + op_types = Counter(op.op_type for op in ops) + f.write(f"操作类型: {dict(op_types)}\n\n") + + thread_ids = set() + for op in ops: + for e in op.entities: + if e.type == 'thread_id': + thread_ids.add(e.value) + + if thread_ids: + f.write(f"Thread IDs: {', '.join(sorted(thread_ids))}\n\n") + + f.write(f"时间范围: {ops[0].time} ~ {ops[-1].time}\n\n") + f.write(f"---\n\n") + + def _write_insights(self): + """写入洞察报告""" + if not self.insights: + return + + path = self.output_dir / "insights.md" + with open(path, 'w', encoding='utf-8') as f: + f.write(f"# 分析洞察\n\n") + + # 按严重程度分组 + by_severity = defaultdict(list) + for insight in self.insights: + by_severity[insight.severity].append(insight) + + for severity in ['critical', 'high', 'medium', 'low']: + if severity not in by_severity: + continue + + f.write(f"## {severity.upper()} 级别\n\n") + for insight in by_severity[severity]: + f.write(f"### {insight.title}\n\n") + f.write(f"**类别:** {insight.category}\n\n") + f.write(f"**描述:** {insight.description}\n\n") + + if insight.evidence: + f.write(f"**证据:**\n") + for e in insight.evidence: + f.write(f"- {e}\n") + f.write(f"\n") + + if insight.recommendation: + f.write(f"**建议:** {insight.recommendation}\n\n") + + f.write(f"---\n\n") + + def _write_json(self): + """写入 JSON 数据""" + path = self.output_dir / "analysis.json" + + data = { + 'file': str(self.input_path), + 'size_mb': self.file_size_mb, + 'log_type': self.log_type.value, + 'total_lines': self.total_lines, + 'time_range': self.time_range, + 'entities': { + k: { + 'unique': len(set(e.value for e in v)), + 'total': len(v), + 'top': Counter(e.value for e in v).most_common(10) + } + for k, v in self.entities.items() + }, + 'stats': {k: dict(v) for k, v in self.stats.items()}, + 'insights': [ + { + 'category': i.category, + 'severity': i.severity, + 'title': i.title, + 'description': i.description, + 'evidence': i.evidence, + 'recommendation': i.recommendation + } + for i in self.insights + ] + } + + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def _get_summary(self) -> dict: + return { + 'log_type': self.log_type.value, + 'total_lines': self.total_lines, + 'entity_types': len(self.entities), + 'operation_count': len(self.operations), + 'insight_count': len(self.insights), + 'output_dir': str(self.output_dir) + } + + +def main(): + parser = argparse.ArgumentParser(description='RAPHL 智能日志分析器') + parser.add_argument('input', help='输入日志文件') + parser.add_argument('-o', '--output', default='./log_analysis', help='输出目录') + + args = parser.parse_args() + + analyzer = SmartLogAnalyzer(args.input, args.output) + result = analyzer.run() + + print(f"\n请查看 {result['output_dir']}/summary.md") + + +if __name__ == '__main__': + main() diff --git a/mcp-builder/SKILL.md b/mcp-builder/SKILL.md new file mode 100644 index 0000000..7fbc41d --- /dev/null +++ b/mcp-builder/SKILL.md @@ -0,0 +1,326 @@ +--- +name: mcp-builder +description: Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK). +license: Complete terms in LICENSE.txt +--- + +# MCP Server Development Guide + +## Overview + +To create high-quality MCP (Model Context Protocol) servers that enable LLMs to effectively interact with external services, use this skill. An MCP server provides tools that allow LLMs to access external services and APIs. The quality of an MCP server is measured by how well it enables LLMs to accomplish real-world tasks using the tools provided. + +--- + +# Process + +## High-Level Workflow + +Creating a high-quality MCP server involves four main phases: + +### Phase 1: Deep Research and Planning + +#### 1.1 Understand Agent-Centric Design Principles + +Before diving into implementation, understand how to design tools for AI agents by reviewing these principles: + +**Build for Workflows, Not Just API Endpoints:** +- Don't simply wrap existing API endpoints - build thoughtful, high-impact workflow tools +- Consolidate related operations (e.g., `schedule_event` that both checks availability and creates event) +- Focus on tools that enable complete tasks, not just individual API calls +- Consider what workflows agents actually need to accomplish + +**Optimize for Limited Context:** +- Agents have constrained context windows - make every token count +- Return high-signal information, not exhaustive data dumps +- Provide "concise" vs "detailed" response format options +- Default to human-readable identifiers over technical codes (names over IDs) +- Consider the agent's context budget as a scarce resource + +**Design Actionable Error Messages:** +- Error messages should guide agents toward correct usage patterns +- Suggest specific next steps: "Try using filter='active_only' to reduce results" +- Make errors educational, not just diagnostic +- Help agents learn proper tool usage through clear feedback + +**Follow Natural Task Subdivisions:** +- Tool names should reflect how humans think about tasks +- Group related tools with consistent prefixes for discoverability +- Design tools around natural workflows, not just API structure + +**Use Evaluation-Driven Development:** +- Create realistic evaluation scenarios early +- Let agent feedback drive tool improvements +- Prototype quickly and iterate based on actual agent performance + +#### 1.2 Study MCP Protocol Documentation + +**Fetch the latest MCP protocol documentation:** + +Use WebFetch to load: `https://modelcontextprotocol.io/llms-full.txt` + +This comprehensive document contains the complete MCP specification and guidelines. + +#### 1.3 Study Framework Documentation + +**Load and read the following reference files:** + +- **MCP Best Practices**: [View Best Practices](./reference/mcp_best_practices.md) - Core guidelines for all MCP servers + +**For Python implementations, also load:** +- **Python SDK Documentation**: Use WebFetch to load `https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` +- [Python Implementation Guide](./reference/python_mcp_server.md) - Python-specific best practices and examples + +**For Node/TypeScript implementations, also load:** +- **TypeScript SDK Documentation**: Use WebFetch to load `https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md` +- [TypeScript Implementation Guide](./reference/node_mcp_server.md) - Node/TypeScript-specific best practices and examples + +#### 1.4 Exhaustively Study API Documentation + +To integrate a service, read through **ALL** available API documentation: +- Official API reference documentation +- Authentication and authorization requirements +- Rate limiting and pagination patterns +- Error responses and status codes +- Available endpoints and their parameters +- Data models and schemas + +**To gather comprehensive information, use web search and the WebFetch tool as needed.** + +#### 1.5 Create a Comprehensive Implementation Plan + +Based on your research, create a detailed plan that includes: + +**Tool Selection:** +- List the most valuable endpoints/operations to implement +- Prioritize tools that enable the most common and important use cases +- Consider which tools work together to enable complex workflows + +**Shared Utilities and Helpers:** +- Identify common API request patterns +- Plan pagination helpers +- Design filtering and formatting utilities +- Plan error handling strategies + +**Input/Output Design:** +- Define input validation models (Pydantic for Python, Zod for TypeScript) +- Design consistent response formats (e.g., JSON or Markdown), and configurable levels of detail (e.g., Detailed or Concise) +- Plan for large-scale usage (thousands of users/resources) +- Implement character limits and truncation strategies (e.g., 25,000 tokens) + +**Error Handling Strategy:** +- Plan graceful failure modes +- Design clear, actionable, LLM-friendly, natural language error messages which prompt further action +- Consider rate limiting and timeout scenarios +- Handle authentication and authorization errors + +--- + +### Phase 2: Implementation + +Now that you have a comprehensive plan, begin implementation following language-specific best practices. + +#### 2.1 Set Up Project Structure + +**For Python:** +- Create a single `.py` file or organize into modules if complex (see [Python Guide](./reference/python_mcp_server.md)) +- Use the MCP Python SDK for tool registration +- Define Pydantic models for input validation + +**For Node/TypeScript:** +- Create proper project structure (see [TypeScript Guide](./reference/node_mcp_server.md)) +- Set up `package.json` and `tsconfig.json` +- Use MCP TypeScript SDK +- Define Zod schemas for input validation + +#### 2.2 Implement Core Infrastructure First + +**To begin implementation, create shared utilities before implementing tools:** +- API request helper functions +- Error handling utilities +- Response formatting functions (JSON and Markdown) +- Pagination helpers +- Authentication/token management + +#### 2.3 Implement Tools Systematically + +For each tool in the plan: + +**Define Input Schema:** +- Use Pydantic (Python) or Zod (TypeScript) for validation +- Include proper constraints (min/max length, regex patterns, min/max values, ranges) +- Provide clear, descriptive field descriptions +- Include diverse examples in field descriptions + +**Write Comprehensive Docstrings/Descriptions:** +- One-line summary of what the tool does +- Detailed explanation of purpose and functionality +- Explicit parameter types with examples +- Complete return type schema +- Usage examples (when to use, when not to use) +- Error handling documentation, which outlines how to proceed given specific errors + +**Implement Tool Logic:** +- Use shared utilities to avoid code duplication +- Follow async/await patterns for all I/O +- Implement proper error handling +- Support multiple response formats (JSON and Markdown) +- Respect pagination parameters +- Check character limits and truncate appropriately + +**Add Tool Annotations:** +- `readOnlyHint`: true (for read-only operations) +- `destructiveHint`: false (for non-destructive operations) +- `idempotentHint`: true (if repeated calls have same effect) +- `openWorldHint`: true (if interacting with external systems) + +#### 2.4 Follow Language-Specific Best Practices + +**For Python: Load [Python Implementation Guide](./reference/python_mcp_server.md) and ensure the following:** +- Using MCP Python SDK with proper tool registration +- Pydantic v2 models with `model_config` +- Type hints throughout +- Async/await for all I/O operations +- Proper imports organization +- Module-level constants (CHARACTER_LIMIT, API_BASE_URL) + +**For Node/TypeScript: Load [TypeScript Implementation Guide](./reference/node_mcp_server.md) and ensure the following:** +- Using `server.registerTool` properly +- Zod schemas with `.strict()` +- TypeScript strict mode enabled +- No `any` types - use proper types +- Explicit Promise return types +- Build process configured (`npm run build`) + +--- + +### Phase 3: Review and Refine + +After initial implementation: + +#### 3.1 Code Quality Review + +To ensure quality, review the code for: +- **DRY Principle**: No duplicated code between tools +- **Composability**: Shared logic extracted into functions +- **Consistency**: Similar operations return similar formats +- **Error Handling**: All external calls have error handling +- **Type Safety**: Full type coverage (Python type hints, TypeScript types) +- **Documentation**: Every tool has comprehensive docstrings/descriptions + +#### 3.2 Test and Build + +**Important:** MCP servers are long-running processes that wait for requests over stdio/stdin or sse/http. Running them directly in your main process (e.g., `python server.py` or `node dist/index.js`) will cause your process to hang indefinitely. + +**Safe ways to test the server:** +- Use the evaluation harness (see Phase 4) - recommended approach +- Run the server in tmux to keep it outside your main process +- Use a timeout when testing: `timeout 5s python server.py` + +**For Python:** +- Verify Python syntax: `python -m py_compile your_server.py` +- Check imports work correctly by reviewing the file +- To manually test: Run server in tmux, then test with evaluation harness in main process +- Or use the evaluation harness directly (it manages the server for stdio transport) + +**For Node/TypeScript:** +- Run `npm run build` and ensure it completes without errors +- Verify dist/index.js is created +- To manually test: Run server in tmux, then test with evaluation harness in main process +- Or use the evaluation harness directly (it manages the server for stdio transport) + +#### 3.3 Use Quality Checklist + +To verify implementation quality, load the appropriate checklist from the language-specific guide: +- Python: see "Quality Checklist" in [Python Guide](./reference/python_mcp_server.md) +- Node/TypeScript: see "Quality Checklist" in [TypeScript Guide](./reference/node_mcp_server.md) + +--- + +### Phase 4: Create Evaluations + +After implementing your MCP server, create comprehensive evaluations to test its effectiveness. + +**Load [Evaluation Guide](./reference/evaluation.md) for complete evaluation guidelines.** + +#### 4.1 Understand Evaluation Purpose + +Evaluations test whether LLMs can effectively use your MCP server to answer realistic, complex questions. + +#### 4.2 Create 10 Evaluation Questions + +To create effective evaluations, follow the process outlined in the evaluation guide: + +1. **Tool Inspection**: List available tools and understand their capabilities +2. **Content Exploration**: Use READ-ONLY operations to explore available data +3. **Question Generation**: Create 10 complex, realistic questions +4. **Answer Verification**: Solve each question yourself to verify answers + +#### 4.3 Evaluation Requirements + +Each question must be: +- **Independent**: Not dependent on other questions +- **Read-only**: Only non-destructive operations required +- **Complex**: Requiring multiple tool calls and deep exploration +- **Realistic**: Based on real use cases humans would care about +- **Verifiable**: Single, clear answer that can be verified by string comparison +- **Stable**: Answer won't change over time + +#### 4.4 Output Format + +Create an XML file with this structure: + +```xml + + + Find discussions about AI model launches with animal codenames. One model needed a specific safety designation that uses the format ASL-X. What number X was being determined for the model named after a spotted wild cat? + 3 + + + +``` + +--- + +# Reference Files + +## Documentation Library + +Load these resources as needed during development: + +### Core MCP Documentation (Load First) +- **MCP Protocol**: Fetch from `https://modelcontextprotocol.io/llms-full.txt` - Complete MCP specification +- [MCP Best Practices](./reference/mcp_best_practices.md) - Universal MCP guidelines including: + - Server and tool naming conventions + - Response format guidelines (JSON vs Markdown) + - Pagination best practices + - Character limits and truncation strategies + - Tool development guidelines + - Security and error handling standards + +### SDK Documentation (Load During Phase 1/2) +- **Python SDK**: Fetch from `https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` +- **TypeScript SDK**: Fetch from `https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md` + +### Language-Specific Implementation Guides (Load During Phase 2) +- [Python Implementation Guide](./reference/python_mcp_server.md) - Complete Python/FastMCP guide with: + - Server initialization patterns + - Pydantic model examples + - Tool registration with `@mcp.tool` + - Complete working examples + - Quality checklist + +- [TypeScript Implementation Guide](./reference/node_mcp_server.md) - Complete TypeScript guide with: + - Project structure + - Zod schema patterns + - Tool registration with `server.registerTool` + - Complete working examples + - Quality checklist + +### Evaluation Guide (Load During Phase 4) +- [Evaluation Guide](./reference/evaluation.md) - Complete evaluation creation guide with: + - Question creation guidelines + - Answer verification strategies + - XML format specifications + - Example questions and answers + - Running an evaluation with the provided scripts diff --git a/searchnews/README.md b/searchnews/README.md new file mode 100644 index 0000000..be448ec --- /dev/null +++ b/searchnews/README.md @@ -0,0 +1,21 @@ +# Search News + +AI 新闻搜索整理技能,从多个新闻源抓取 AI 相关新闻。 + +## 依赖 + +```bash +brew install jq # macOS 通常已预装 +``` + +## 新闻源 + +- AIBase 日报 +- IT 之家 +- 36氪 +- 机器之心 +- 量子位 + +## 使用 + +加载 skill 后,告诉 Agent 要搜索的日期即可,输出到 `dailynews/YYYY-MM-DD/` 目录。 diff --git a/searchnews/SKILL.md b/searchnews/SKILL.md new file mode 100644 index 0000000..efc37ef --- /dev/null +++ b/searchnews/SKILL.md @@ -0,0 +1,376 @@ +--- +name: searchnews +description: 当用户要求"搜索新闻"、"查询AI新闻"、"整理新闻"、"获取某天的新闻",或提到需要搜索、整理、汇总指定日期的AI行业新闻时,应使用此技能。 +metadata: + version: "0.4.0" +--- + +# AI新闻搜索技能 (Ralph Loop 增强版) + +## 概述 + +此技能用于从多个AI新闻源精确搜索指定日期的新闻,采用 Ralph Loop 模式进行地毯式迭代,确保不留死角。 + +## 核心机制 (Ralph Loop) + +### 1. 任务清单 (prd.json) +记录待爬取的源网站及其状态。 +- `date`: 目标日期,格式 YYYY-MM-DD +- `keywords_ref`: 引用关键词库文件路径(如 `references/keywords.md`),搜索时加载 10 大分类和 100+ 标签进行筛选 +- `sources`: 每个源包含 `name`, `url`, `status` (pending/done/failed), `retry_count` (max 3) + +### 2. 退出逻辑 (目标导向) +- **成功退出**:当所有 `sources` 状态均为 `done` 时,输出 `COMPLETE` 并立即停止循环。 +- **失败容错**:单个源抓取失败时,最多尝试 **3次**。若 3 次均失败,将状态标记为 `failed`,记录失败原因,跳过该源。 +- **高效收工**:一旦所有源都处理完毕(状态为 `done` 或 `failed`),立即生成最终日报并交付,不强制跑完预设的最大轮次。 + +## 必抓源列表(按优先级排序) + +| 优先级 | 源名称 | URL | 说明 | +|--------|--------|-----|------| +| **高** | AIBase日报 | https://news.aibase.com/zh/daily | 每日AI新闻汇总,必抓!内容精炼、覆盖全面 | +| 中 | IT之家AI频道 | https://next.ithome.com/ | 国内科技资讯,AI专栏 | +| 中 | 36氪AI频道 | https://36kr.com/information/AI/ | 创投视角,AI产业报道 | +| 中 | 机器之心 | https://www.jiqizhixin.com/articles | 专业AI媒体,技术深度 | +| 中 | 量子位 | https://www.qbitai.com | AI前沿,产品报道 | + +> **注意**:AIBase日报通常在当天发布,内容即为当天新闻汇总,是最高效的信息源。 + +## 工作流程 + +### ⚠️ 铁律:必须使用 Ralph 脚本启动! + +**禁止手动乱抓!** 必须严格按以下流程执行: + +```bash +# 第零步:启动 Ralph Loop(必须执行!) +bash .opencode/skills/searchnews/scripts/ralph/ralph.sh 2026-01-19 +``` + +脚本会自动初始化 `prd.json`,然后 Agent 按任务清单逐个处理。 + +### 第一步:初始化任务清单(由脚本完成) +脚本会在 `.opencode/skills/searchnews/scripts/ralph/prd.json` 中生成源网站列表,初始状态均为 `pending`。**AIBase日报必须放在第一位,优先抓取。** + +### 第二步:地毯式循环搜索 +1. 读取 `prd.json` 中处于 `pending` 状态的源。 +2. **每处理一个源,必须更新 prd.json 状态**(pending → done/failed)。 +3. **每轮迭代必须写入 progress.txt**,记录进度和失败原因。 +4. 严格校验日期,仅保留目标日期的内容。 +5. 抓取失败时 `retry_count + 1`,最多重试3次。 + +### 第二点五步:深度检索(重要!) +**禁止只抓列表页!** 对于筛选出的重要新闻,必须深入到详情页抓取: +1. 从列表页提取新闻详情 URL +2. **逐条访问详情页**,获取完整内容 +3. 提取关键信息: + - 完整正文(不是摘要) + - 技术细节、数据指标 + - 原始来源/论文链接 + - 划重点/要点总结 +4. 深度检索的新闻质量远高于列表页复制粘贴 + +> **示例**:AIBase日报列表页只有标题和简介,但详情页有完整的技术解读、数据对比、划重点等深度内容。 + +### 第三步:去重与聚合 +合并不同来源的相同新闻,保留详情最丰富的版本,合并标注来源。 + +### 第四步:输出结构化文档 +文件存储在 `dailynews/YYYY-MM-DD/YYYY-MM-DD.md`(每日独立文件夹)。 + +#### 输出格式模板(必须严格遵守!) + +```markdown +--- +date: YYYY-MM-DD +type: 新闻日报 +tags: [AI新闻, 日报] +--- + +# AI新闻日报 - YYYY-MM-DD + +> 日期校验: 已通过 | 仅包含YYYY-MM-DD发布的新闻 | 已去重 + +--- + +## 1. 新闻标题 + +**分类**: 分类标签 | **来源**: 来源网站 | **时间**: YYYY/M/D HH:MM + +一句话摘要,概括新闻核心内容。 + +**详情**: +- 详情要点1(包含具体数据、指标) +- 详情要点2 +- 详情要点3 +- 详情要点4(可选) +- 详情要点5(可选) + +--- + +## 2. 下一条新闻标题 +... + +*数据来源: 来源列表 | 整理时间: YYYY-MM-DD* +``` + +#### 格式要点 +1. **每条新闻必须包含**:编号标题、分类|来源|时间、摘要、详情要点(3-5条) +2. **详情要点必须包含具体数据**:金额、百分比、时间节点、技术指标等 +3. **分类标签参考**:AI基础设施、AI产品、投融资、机器人、商业化、AI监管、行业观点、企业战略、AI能力、趣闻等 +4. **时间格式**:精确到分钟(如 2026/1/19 15:28) +5. **新闻数量要求**:每日至少整理 10-20 条新闻,不得偷懒只抓几条! + +### 第五步:确认完成 +当所有源状态均为 `done` 或 `failed` 时,输出: +``` +COMPLETE +``` + +## 质量要求 + +- [ ] **必用脚本**:必须先执行 `ralph.sh` 初始化,禁止手动乱抓! +- [ ] **状态追踪**:每处理一个源必须更新 `prd.json` 状态。 +- [ ] **进度记录**:每轮迭代必须写入 `progress.txt`。 +- [ ] **必抓AIBase**:AIBase日报是必抓源,每次整理新闻必须首先访问。 +- [ ] **深度检索**:禁止只抓列表页!重要新闻必须深入详情页获取完整内容。 +- [ ] **全量覆盖**:必须尝试清单中所有的源网站。 +- [ ] **日期铁律**:严禁混入非目标日期的新闻。 +- [ ] **标签映射**:必须对照 10 大分类进行精准打标。 +- [ ] **详情完整**:包含标题、摘要、3-5条详情要点、溯源链接、精确时间。 +- [ ] **循环退出**:所有源 done/failed 后才输出 `COMPLETE`。 + +## ⛔ 输出铁律(违反即解雇!) + +### 被剔除的新闻禁止输出! +1. **只输出符合日期的新闻**:最终日报中只能出现目标日期的新闻 +2. **剔除的不要提**:因日期不符被剔除的新闻,**禁止在任何地方输出或提及** +3. **不要显示剔除过程**:不要告诉用户"我剔除了 xx 条"、"以下是被过滤的"等废话 +4. **静默过滤**:日期校验是内部逻辑,用户只需要看到最终结果,不需要知道你筛掉了什么 +5. **简洁交付**:只输出干净的、符合日期的新闻列表,没有任何多余说明 + +**错误示例(禁止!)**: +``` +以下新闻因日期不符已剔除: +- xxx(1月18日) +- yyy(1月20日) +``` + +**正确做法**: +静默跳过不符合日期的新闻,只输出符合的,一个字都不要多说。 + +## 资源引用 + +- **scripts/ralph/ralph.sh** - 启动主循环。 +- **scripts/ralph/prd.json** - 动态任务清单。 +- **scripts/ralph/progress.txt** - 迭代进度与重试日志。 +- **references/keywords.md** - 10 大分类 100+ 标签地图。 +- **templates/** - 视频风格模板库。 + +--- + +## 第六步:生成新闻视频(可选) + +新闻日报整理完成后,可生成AI新闻视频。 + +### 6.1 交互流程(必须询问!) + +收到"生成新闻视频"请求后,**必须依次询问**: + +#### 问题1:确认日期 +``` +生成哪天的新闻视频? +- 今天 (YYYY-MM-DD) +- 昨天 (YYYY-MM-DD) +- 自定义日期 +``` + +#### 问题2:是否使用风格模板 +``` +是否使用提示词库中的风格模板? +- 是,使用模板 (推荐) - 从21种预设风格中选择,风格统一 +- 否,自由生成 - 不使用模板,AI自由发挥 +``` + +**如果选择"使用模板",继续问题3;否则跳到问题4** + +#### 问题3:选择视觉风格(21种) + +**风格提示词库位置**:`{prompts_dir}/图片生成风格/AI新闻早报风格/` + +``` +选择配图风格: + +【科技感】 +- 默认风格-Dashboard (推荐) - 科技仪表盘,数据可视化 +- 赛博未来风 - 霓虹赛博朋克 +- 科技媒体封面风 - 新闻媒体封面感 +- AI操作系统界面风 - JARVIS控制台风格 +- 深色金融终端风 - Bloomberg终端感 +- 全息投影风 - 全息科幻 +- 量子科幻风 - 量子粒子效果 + +【简约风】 +- 毛玻璃拟态风 - 苹果风毛玻璃 +- 信息图表风 - 数据信息图 +- 极简信息设计风 - 扁平极简 + +【特色风】 +- 未来报纸头版 - 报纸版式 +- 杂志封面风 - 杂志风格 +- 漫画分镜风 - 漫画格子 +- 太空宇宙风 - 星空宇宙 +- 水墨国风 - 中国风水墨 +- 复古像素风 - 8bit像素 +- 霓虹波普风 - 波普艺术 +- 工程蓝图风 - 技术蓝图 +- 自然有机风 - 环保自然 +- 未来实验室风 - 实验室科研 +- 社交媒体爆款风 - 抖音小红书 +``` + +#### 问题4:生成模式 +``` +选择生成模式: +- 完整版(总览+详情)(推荐) - 1张总览图 + N张详情图 +- 仅总览 - 只生成1张总览图 +- 仅详情 - 只生成N张详情图 +``` + +#### 问题5:新闻数量(如果超过10条) +``` +日报共有XX条新闻,如何处理? +- 全部生成 +- 精选10条 - 自动挑选最重要的 +- 精选5条 - 只做头条 +``` + +### 6.2 加载并使用风格模板 + +#### 步骤1:读取模板文件 +``` +{prompts_dir}/图片生成风格/AI新闻早报风格/{风格名}.md +``` + +#### 步骤2:提取"完整提示词模板" +每个风格文件都包含 `## 完整提示词模板` 段落,提取其中的提示词。 + +#### 步骤3:替换变量 +| 变量 | 替换内容 | 示例 | +|------|----------|------| +| `{日期}` | 日报日期 | 2026年01月23日 | +| `{N}` | 新闻条数 | 25 | +| `{新闻列表}` | 编号+标题列表 | 1. ChatGPT Atlas更新... | + +#### 步骤4:生成总览图 +用替换后的提示词调用 image-service: +```bash +python .opencode/skills/image-service/scripts/text_to_image.py \ + "{替换变量后的完整提示词}" -r 16:9 -o "assets/video/{日期}/00_overview.png" +``` + +#### 步骤5:生成详情图 +每条新闻单独生成,提示词结构: +``` +AI新闻详情配图 - {风格名} + +【新闻标题】{标题} + +【新闻要点】 +- {要点1} +- {要点2} +- {要点3} + +【视觉要求】 +- 沿用{风格名}的视觉风格 +- 中心突出新闻主题的3D/扁平化插图 +- 标题大字清晰,要点用图标化卡片展示 +- 底部水印:{your_watermark} + +输出尺寸:2560x1440 横版 16:9 +``` + +### 6.3 视频生成流程 + +#### 目录结构 +``` +assets/video/{YYYY-MM-DD}/ +├── 00_overview.png # 总览图 +├── 01_xxx.png # 详情图1 +├── 02_xxx.png # 详情图2 +├── ... +├── audio/ +│ ├── 00_overview.mp3 # 总览配音 +│ ├── 01.mp3 # 详情配音1 +│ └── ... +├── video.yaml # 合成配置 +└── {日期}_ai_news.mp4 # 最终视频 +``` + +#### 生成命令 + +```bash +# 1. 创建目录 +mkdir -p "assets/video/{日期}/audio" + +# 2. 并发生成配图(使用 text_to_image) +python .opencode/skills/image-service/scripts/text_to_image.py \ + "{风格提示词}" -r 16:9 -o "assets/video/{日期}/00_overview.png" + +# 3. 并发生成配音 +python .opencode/skills/video-creator/scripts/tts_generator.py \ + --text "{配音文本}" \ + --voice zh-CN-YunyangNeural \ + --output "assets/video/{日期}/audio/XX.mp3" + +# 4. 合成视频 +python .opencode/skills/video-creator/scripts/video_maker.py \ + assets/video/{日期}/video.yaml +``` + +### 6.4 配音规范 + +| 场景 | 文本模板 | +|------|----------| +| 总览 | "AI早报,{日期}。今天共有{N}条AI行业重磅新闻,让我们一起来看看!" | +| 详情 | "第X条,{标题}。{摘要}" | +| 结尾 | 最后一条追加"以上就是今天的AI早报,感谢收看!" | + +**音色选择**: +- `zh-CN-YunyangNeural` - 男声,新闻播报(推荐) +- `zh-CN-YunxiNeural` - 男声,阳光活泼 +- `zh-CN-XiaoxiaoNeural` - 女声,温暖自然 + +### 6.5 视频配置模板 (video.yaml) + +```yaml +output: {YYYY-MM-DD}_ai_news.mp4 + +scenes: + - image: 00_overview.png + audio: audio/00_overview.mp3 + - image: 01_xxx.png + audio: audio/01.mp3 + # ... 依次列出所有场景 +``` + +### 6.6 完成后输出 + +``` +✅ 视频生成完成! + +📍 位置:assets/video/{日期}/{日期}_ai_news.mp4 +⏱️ 时长:X分X秒 +🎬 场景:X个(1总览 + X详情) +🎨 风格:{选择的风格} + +是否打开预览? +``` + +### 6.7 注意事项 + +1. **并发生成**:配图和配音都要并发,提升效率 +2. **水印**:所有配图底部必须添加水印 +3. **片尾**:视频自动拼接通用片尾 +4. **BGM**:自动添加科技风背景音乐 +5. **比例**:所有配图使用 16:9 横版 diff --git a/searchnews/references/keywords.md b/searchnews/references/keywords.md new file mode 100644 index 0000000..78cdf7e --- /dev/null +++ b/searchnews/references/keywords.md @@ -0,0 +1,31 @@ +# AI 全维度关键词库 (Ralph Loop 搜索基准) + +## 一、基础 & 通用 AI 标签 +#AI #人工智能 #智能科技 #前沿科技 #未来科技 #数字智能 #智能时代 #科技趋势 #智能革命 #下一代科技 + +## 二、大模型 / 底层能力 +#大模型 #基础模型 #通用人工智能 #AGI #多模态 #语言模型 #视觉模型 #生成模型 #模型训练 #模型推理 + +## 三、生成式 AI +#生成式AI #AIGC #AI绘画 #AI写作 #AI视频 #AI设计 #AI作曲 #AI配音 #AI图像生成 #AI内容创作 + +## 四、智能体 / Agent 体系 +#智能体 #AI智能体 #Agent #多智能体 #AI自动化 #任务型智能体 #自主智能体 #工具调用 #AI协作 #AI执行引擎 + +## 五、提示词 & 人机交互 +#提示词 #Prompt #Prompt工程 #提示词设计 #人机交互 #自然语言交互 #对话式AI #指令工程 #AI沟通方式 #AI思维 + +## 六、AI 工程 / 开发 / 技术向 +#AI工程 #AI开发 #模型部署 #模型微调 #RAG #向量数据库 #AI架构 #AI系统设计 #AI中台 #AI产品化 + +## 七、AI 产品 & 应用落地 +#AI产品 #AI应用 #AI助手 #AI工具 #智能办公 #AI营销 #AI客服 #AI教育 #AI医疗 #AI商业化 + +## 八、趋势 / 认知 / 思想层 +#AI趋势 #AI认知升级 #AI时代 #AI变革 #AI生产力 #AI替代 #AI赋能 #人与AI #智能社会 #技术浪潮 + +## 九、内容传播 & 平台友好标签 +#科技科普 #硬核科技 #科技博主 #科技认知 #效率工具 #认知提升 #工具推荐 #生产力工具 #数字生活 #未来职业 + +## 十、偏前沿 & 概念向 +#数字生命 #虚拟智能 #AI意识 #机器智能 #智能进化 #人机共生 #智能文明 #类人智能 #AI哲学 #未来已来 diff --git a/searchnews/scripts/ralph/prd.json b/searchnews/scripts/ralph/prd.json new file mode 100644 index 0000000..539c9b7 --- /dev/null +++ b/searchnews/scripts/ralph/prd.json @@ -0,0 +1,39 @@ +{ + "date": "2026-01-25", + "keywords_ref": "references/keywords.md", + "sources": [ + { + "name": "AIBase日报", + "url": "https://news.aibase.com/zh/daily", + "status": "failed", + "priority": "high", + "note": "1月25日日报尚未发布" + }, + { + "name": "IT之家智能时代", + "url": "https://next.ithome.com/", + "status": "done", + "news_count": 10 + }, + { + "name": "36氪", + "url": "https://36kr.com/information/AI/", + "status": "done", + "news_count": 0, + "note": "列表页无1月25日新闻" + }, + { + "name": "机器之心", + "url": "https://www.jiqizhixin.com/articles", + "status": "done", + "news_count": 0 + }, + { + "name": "量子位", + "url": "https://www.qbitai.com", + "status": "done", + "news_count": 3 + } + ], + "is_complete": true +} diff --git a/searchnews/scripts/ralph/prd.template.json b/searchnews/scripts/ralph/prd.template.json new file mode 100644 index 0000000..def116f --- /dev/null +++ b/searchnews/scripts/ralph/prd.template.json @@ -0,0 +1,12 @@ +{ + "date": "YYYY-MM-DD", + "keywords_ref": "references/keywords.md", + "sources": [ + {"name": "AIBase日报", "url": "https://news.aibase.com/zh/daily", "status": "pending", "priority": "high"}, + {"name": "IT之家智能时代", "url": "https://next.ithome.com/", "status": "pending"}, + {"name": "36氪", "url": "https://36kr.com/information/AI/", "status": "pending"}, + {"name": "机器之心", "url": "https://www.jiqizhixin.com/articles", "status": "pending"}, + {"name": "量子位", "url": "https://www.qbitai.com", "status": "pending"} + ], + "is_complete": false +} diff --git a/searchnews/scripts/ralph/progress.txt b/searchnews/scripts/ralph/progress.txt new file mode 100644 index 0000000..14363bf --- /dev/null +++ b/searchnews/scripts/ralph/progress.txt @@ -0,0 +1,16 @@ +Ralph Loop Progress - 2026-01-24 +================================ + +[Round 1] 2026-01-24 15:xx +- AIBase日报: done (最新日报为1月23日发布,提取相关内容) +- IT之家: done (获取10+条1月24日新闻) +- 36氪: done (获取多条AI相关新闻) +- 机器之心: done (页面加载成功) +- 量子位: done (获取多条热门新闻) + +[Summary] +- 所有源抓取完成 +- 共整理17条新闻 +- 日报已生成: dailynews/2026-01-24/news.md + +COMPLETE diff --git a/searchnews/scripts/ralph/ralph.sh b/searchnews/scripts/ralph/ralph.sh new file mode 100644 index 0000000..806aa96 --- /dev/null +++ b/searchnews/scripts/ralph/ralph.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Ralph Loop 启动脚本 - 仅初始化,不循环 + +DATE=${1:-$(date +%Y-%m-%d)} +PRD_FILE=".opencode/skills/searchnews/scripts/ralph/prd.json" +PRD_TEMPLATE=".opencode/skills/searchnews/scripts/ralph/prd.template.json" + +# 从模板创建/重置 prd.json +if [ -f "$PRD_TEMPLATE" ]; then + jq --arg date "$DATE" '.date = $date | .sources[].status = "pending" | .is_complete = false' "$PRD_TEMPLATE" > "$PRD_FILE" + echo "Initialized prd.json for $DATE" +else + echo "Error: Template not found!" + exit 1 +fi + +echo "" +echo "Sources to crawl:" +jq -r '.sources[] | " [\(.priority // "normal")] \(.name): \(.url)"' "$PRD_FILE" +echo "" +echo "Ready! Agent will now process each source and update prd.json status." diff --git a/searchnews/templates/README.md b/searchnews/templates/README.md new file mode 100644 index 0000000..654c6e9 --- /dev/null +++ b/searchnews/templates/README.md @@ -0,0 +1,50 @@ +# 新闻视频模板库 + +## 可用模板 + +| 模板名称 | 文件 | 风格描述 | 适用场景 | +|---------|------|----------|----------| +| 黑板报粉笔风 | `blackboard_chalk.png` | 深绿黑板背景、彩色粉笔手绘、温馨有趣 | 小红书、抖音、日常分享 | + +## 使用方法 + +生成视频时,Agent 会询问选择哪个模板,然后基于模板风格生成所有新闻卡片。 + +## 添加新模板 + +1. 将模板图片放入此目录 +2. 更新此 README 的模板列表 +3. 在 SKILL.md 中添加模板的提示词描述 + +## 模板提示词参考 + +### blackboard_chalk(黑板报粉笔风) + +``` +手绘黑板报风格AI新闻卡片,3:4竖版。深绿色黑板背景带粉笔质感。 +左上角{颜色}粉笔标签'{分类}'。 +白色粉笔大标题'{标题}'。 +用彩色粉笔手绘可视化:{根据内容描述简笔画图标}。 +要点用黄色粉笔:{要点1}、{要点2}、{要点3}、{要点4}。 +右下角小字'{来源}' +``` + +### 封面模板 + +``` +手绘黑板报风格AI新闻日报封面,3:4竖版。深绿色黑板背景带粉笔质感。 +顶部用超大白色粉笔手写'AI日报',下方黄色粉笔写'{日期}'。 +中间用白色粉笔整齐列出所有新闻标题(手写风格): +{新闻标题列表} +周围点缀粉笔星星和小装饰 +``` + +### 结尾模板 + +``` +手绘黑板报风格AI新闻日报结尾页,3:4竖版。深绿色黑板背景粉笔质感。 +中间用超大白色粉笔手写'今日份AI已送达'。 +下方用黄色粉笔画一个可爱的机器人挥手。 +用粉色粉笔写'点赞+收藏+关注',旁边画爱心、星星、加号图标。 +底部用蓝色粉笔写'明天见!',周围点缀彩色粉笔星星和小花装饰。 +``` diff --git a/searchnews/templates/blackboard_chalk.png b/searchnews/templates/blackboard_chalk.png new file mode 100644 index 0000000000000000000000000000000000000000..ffef82b03f3a2b325247e394bea9e5cd2e80bef3 GIT binary patch literal 1497061 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