diff --git a/.gitignore b/.gitignore
index 37932fb4..a6438d2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,10 @@ sync_tokens.env
**/智能纪要/脚本/feishu_user_token.txt
**/智能纪要/脚本/cookie_minutes.txt
+# 卡若AI 网关:多租户配置与访问日志(不入库)
+运营中枢/scripts/karuo_ai_gateway/config/gateway.yaml
+运营中枢/工作台/karuo_ai_gateway_access.jsonl
+
# Node / 前端
node_modules/
.next/
@@ -90,6 +94,7 @@ _大文件外置/财务管理_data/chat.snapshot_收集.db
# === 自动排除:超过20MB的文件(脚本自动管理,勿手动修改)===
.venv/lib/python3.14/site-packages/playwright/driver/node
.venv_mem0/lib/python3.14/site-packages/grpc/_cython/cygrpc.cpython-314-darwin.so
+.venv_ppt/lib/python3.14/site-packages/playwright/driver/node
01_卡资(金)/金仓_存储备份/大文件外置/消息中枢_dist/windows控制包.zip
01_卡资(金)/金仓_存储备份/大文件外置/视频切片_models/ggml-small.bin
01_卡资(金)/金仓_存储备份/大文件外置/财务管理_data/chat.snapshot_data.db
@@ -98,4 +103,5 @@ _大文件外置/财务管理_data/chat.snapshot_收集.db
03_卡木(木)/木叶_视频内容/视频切片/切片动效包装/10秒视频/node_modules/.cache/webpack/remotion-production-4.0.427/a233e9cccba253c3b0157f54cad843b8/0.pack
03_卡木(木)/木叶_视频内容/视频切片/切片动效包装/10秒视频/node_modules/.remotion/chrome-headless-shell/mac-x64/chrome-headless-shell-mac-x64/chrome-headless-shell
03_卡木(木)/木叶_视频内容/视频切片/切片动效包装/10秒视频/node_modules/@rspack/binding-darwin-x64/rspack.darwin-x64.node
+03_卡木(木)/木果_项目模板/PPT制作/.venv/lib/python3.14/site-packages/playwright/driver/node
# === 自动排除结束 ===
diff --git a/02_卡人(水)/水溪_整理归档/记忆系统/structured/last_chat_collect_date.txt b/02_卡人(水)/水溪_整理归档/记忆系统/structured/last_chat_collect_date.txt
index 0ec317cc..e1186354 100644
--- a/02_卡人(水)/水溪_整理归档/记忆系统/structured/last_chat_collect_date.txt
+++ b/02_卡人(水)/水溪_整理归档/记忆系统/structured/last_chat_collect_date.txt
@@ -1 +1 @@
-2026-02-22
\ No newline at end of file
+2026-02-24
\ No newline at end of file
diff --git a/03_卡木(木)/木果_项目模板/PPT制作/脚本/公司财务分析PPT_毛玻璃.html b/03_卡木(木)/木果_项目模板/PPT制作/脚本/公司财务分析PPT_毛玻璃.html
new file mode 100644
index 00000000..59860be4
--- /dev/null
+++ b/03_卡木(木)/木果_项目模板/PPT制作/脚本/公司财务分析PPT_毛玻璃.html
@@ -0,0 +1,622 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
CFO Plain Talk · Multi-Dimensional
+
公司财务盘点与现金流体检
+
大白话版本:把钱从哪来、花到哪去、哪里不对劲,一次说清楚
+
+ 🧾 统一口径
+ 💳 银行-卡号
+ 🧹 同卡号去重
+ 📈 多维报表
+ 🧠 CFO 结论
+
+
数据口径:统一流水去重后(支付宝 + 短信银行 + 公司收支:卡卡猫→中信0405)
+
+
+
+
+
+
Executive Summary
+
+
+
+
+ 🧠
+ 一句话
+
+
+
这份口径里:净现金流是 -214 万。
+
但最大问题不是“真亏这么多”,而是:短信里混进了额度/提醒,被当成支出。
+
我的结论:先把口径变干净,再谈控制成本和增长。
+
+
+
+ ✅ 你真正要盯的:中信银行-0405(卡卡猫)回款节奏 + 固定支出节奏 + 现金余粮。
+
+
+
+
+
+
+
+
+
+
+
+
Data Rules
+
+
+
+
+ 🧾
+ 口径说人话
+
+
+
① 银行:按“银行-卡号”分开(比如 中信银行-0405)。
+
② 合并:卡卡猫 = 中信银行-0405(短信和公司收支算同一户)。
+
③ 去重:同日期 + 同金额 + 同方向 + 同对方 → 直接去掉重复。
+
④ 时间:每一笔都强制带日期+时间节点。
+
+
+
+
你现在的“统一流水去重后”总笔数:6,446 笔
+
+
+
+
+
+
+
+
+
+
+
+
Numbers
+
+
+
+
+ 📈
+ 先看四个数
+
+
+
+
2,198,237.84
+
总收入(元)
+
+
+
4,342,873.62
+
总支出(元)
+
+
+
-2,144,635.78
+
净现金流(元)
+
+
+
+
大白话:进来的钱 ≈ 220 万,出去的钱 ≈ 434 万,所以净流出 ≈ 214 万。
+
+
+ ⚠️ 重要提醒:支出里有大量“短信噪音”(额度/提醒/营销),这会把支出夸大。
+
+
+
+
+
+
+
+
+
+
+
+
Trend
+
+
+
+
+ 📉
+ 年度怎么走
+
+
+
2017~2024:整体平稳(收支接近)。
+
2025:波动大(收入 110.8 万,支出 145.6 万)。
+
2026:支出异常大(184.9 万) → 优先核对短信噪音。
+
+
+
+ CFO 大白话:趋势里只要出现“突然暴涨/暴跌”,第一件事不是惊慌,是先问:数据口径是不是变了?
+
+
+
+
+
+
+
+
+
+
+
+
Accounts
+
+
+
+
+ 💳
+ 钱主要在哪两条线
+
+
+
收入结构:中信银行-0405 占 50.6%,支付宝占 49.1%。
+
大白话:公司收入基本就是两条腿走路。
+
支出结构里:“其他”非常大 → 先当“待核对”。
+
+
+
+ ✅ 财务总监盯盘顺序:先盯“现金进出最大的账户”,也就是中信0405;其次才是零碎账户。
+
+
+
+
+
+
+
+
+
+
+
+
CITIC 0405
+
+
+
+
+ 🏦
+ 卡卡猫 = 中信银行-0405
+
+
+
收入:1,112,568.09(约 111.3 万)
+
支出:1,013,829.59(约 101.4 万)
+
净额:+98,738.50(约 +9.9 万)
+
+
+
+ CFO 大白话:这户总体是“能自己养活自己”的,但月度上会出现回款空窗期,需要现金阈值和预算上限。
+
+
+
+
+
+
+
+
+
+
+
+
Alipay
+
+
+
+
+ 📲
+ 支付宝这条线
+
+
+
收入:1,080,038.40
+
支出:1,070,871.17
+
净额:+9,167.23
+
手续费:约 2,154 元(属于“交易成本”)
+
+
+
+ ✅ 大白话:支付宝整体“基本打平”,它更像一个收款/分发通道,不是亏损黑洞。
+
+
+
+
+
+
+
+
+
+
+
+
Expense Red Flags
+
+
+
+
+ 🚨
+ 支出 Top 的“真假判断”
+
+
+
看到这些就先别慌:它们很可能不是“真支出”。
+
例子:“授予额度 398000”、“剩余额度 0.17 元”、营销提醒。
+
我的建议:把“短信提醒类”单独一栏,别和真实流水混在一起。
+
+
+
+ 🧹 下一次迭代:给短信解析加“过滤规则”(额度/授信/提醒/预测/账单非交易),让报表更接近真实。
+
+
+
+
+
+
+
+
+
+
+
+
CFO Playbook
+
+
+
+
+ 🧩
+ 先保命,再增长
+
+
+
第 1 件:现金能撑多久(按“固定成本/月”算)。
+
第 2 件:支出有没有上限(预算 + 审批)。
+
第 3 件:回款有没有节奏(每周盯应收)。
+
+
+
+ ✅ 大白话:财务总监最怕的不是“利润低”,是“现金断”。现金断了,业务再好也没用。
+
+
+
+
+
+
+
+
+
+
+
+
30-Day Plan
+
+
+
+
+ 🗓️
+ 30 天迭代计划
+
+
+
第 1 周:把短信噪音剔除(额度/提醒/营销)→ 报表不再“虚胖”。
+
第 2 周:建立预算科目(工资/税/云/房租/外包/营销)。
+
第 3 周:做 13 周现金预测(每周滚动)。
+
第 4 周:固定仪表盘(每周 10 分钟看一次)。
+
+
+
+ ✅ 你最终要的是“自动化财务雷达”:一眼发现异常,一键追溯明细。
+
+
+
+
+
+
+
+
+
+
+
+
+
✅
+
最后一句大白话
+
+ 报表不是用来“看热闹”的,
+ 是用来“提前发现风险、提前踩刹车、提前安排回款”的。
+
+
+ 🧾 口径干净
+ 💳 账户清楚
+ 🧠 结论能落地
+ 📈 每周滚动预测
+
+
下一步:我可以继续把“短信噪音过滤规则”做成自动更新,让 2026 年异常支出回归真实。
+
+
+
+
+
diff --git a/03_卡木(木)/木果_项目模板/PPT制作/脚本/公司财务月报_2026年1月PPT_毛玻璃.html b/03_卡木(木)/木果_项目模板/PPT制作/脚本/公司财务月报_2026年1月PPT_毛玻璃.html
new file mode 100644
index 00000000..80fc600c
--- /dev/null
+++ b/03_卡木(木)/木果_项目模板/PPT制作/脚本/公司财务月报_2026年1月PPT_毛玻璃.html
@@ -0,0 +1,449 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Monthly CFO Report · Plain Talk
+
2026年1月 · 公司财务月报
+
十页左右|把“钱从哪来、花到哪去、下月怎么管”讲明白
+
+ 🏦 卡卡猫·中信0405
+ 🧾 支付宝·企业
+ 🌊 芸归喜·网商9532
+ 📩 短信口径
+
+
数据来源:`2026年1月财务报表_完整表格.md`(生成:2026-02-08)
+
+
+
+
+
+
Executive Summary
+
+
+
+
+ 🧠
+ 一句话结论
+
+
+
1月:公司账户核心结论就是一句话:卡卡猫(中信0405)单月净流出 17.99 万。
+
支付宝企业本月是小幅净流入 +240.71;芸归喜只有收入 5.94。
+
+
+
+ ✅ CFO 大白话:这月不是“赚不赚钱”的问题,是“现金流怎么扛”的问题。
+
+
+
+
+
+
+
+
+
+
+
Scope
+
+
+
+
+ 🧾
+ 这月报表怎么用
+
+
+
我们优先把“可确认”的钱算清楚:卡卡猫中信0405、支付宝企业、芸归喜网商9532。
+
短信口径是“实时提醒”:更像雷达,不是最终账。
+
腾讯云消费:本月待补(通常次月出账单)。
+
+
+
+ ⚠️ 待补项(本月):云消费(腾讯云)、飞书报销/付款明细。
+
+
+
+
+
+
+
+
+
+
+
Overview
+
+
+
+
+ 📋
+ 总览表(大白话)
+
+
+ | 短信口径(实时) | 净 +3,715 |
+ | 卡卡猫(中信0405) | 净 -179,853.23 |
+ | 芸归喜(网商9532) | 收入 5.94 |
+ | 支付宝(企业) | 净 +240.71 |
+ | 飞书工资(参考) | 65,500 |
+ | 云消费(腾讯云) | 待补 |
+
+
+ 大白话:这月现金压力主要来自卡卡猫;支付宝这条线是“小打小闹”;短信口径是“雷达提示”。
+
+
+
+
+
+
+
+
+
+
+
+
CITIC 0405
+
+
+
+
+ 🏦
+ 卡卡猫(中信0405)
+
+
+
收入:0(本月无转入)
+
支出:179,853.23
+
净现金流:-179,853.23
+
+
+
+ CFO 大白话:没有收入的月份,支出再“合理”也会把现金吃掉。这个月就是典型“现金压力月”。
+
+
+
+
+
+
+
+
+
+
+
Alipay (Enterprise)
+
+
+
+
+ 📲
+ 支付宝企业号
+
+
+
收入:1,301.90(5 笔)
+
支出:1,061.19(10 笔)
+
净额:+240.71
+
+
+
+ ✅ 大白话:支付宝这条线本月是“小幅净流入”,它更像收款/分发通道。
+
+
+
+
+
+
+
+
+
+
+
Small Lines
+
+
+
+
+ 🌊
+ 芸归喜 & 飞书
+
+
+
芸归喜(网商9532):收入 5.94(体量很小)。
+
飞书工资(参考):65,500(若属公司刚性成本,需纳入预算)。
+
+
+
+ CFO 大白话:小账户别花太多精力,重点是把刚性成本“预算化”,避免现金月爆雷。
+
+
+
+
+
+
+
+
+
+
+
SMS Radar
+
+
+
+
+ 📩
+ 短信口径(实时)
+
+
+
本月短信口径:收入 4,885 / 支出 1,170 / 净 3,715。
+
大白话:短信更像“预警雷达”,帮你发现“突然发生了什么”。
+
但它不适合当最终账:可能混入提醒/营销/额度。
+
+
+
+ ✅ 用法建议:短信用来“抓异常”;最终对账以重点机构交易明细 & 银行对账单为准。
+
+
+
+
+
+
+
+
+
+
+
Risks & Actions
+
+
+
+
+ 🚨
+ 本月风险点
+
+
+
风险 1:中信0405 无收入但支出 17.99 万 → 现金压力月。
+
风险 2:云消费待补 → 月末可能再来一刀。
+
风险 3:工资/税/固定服务费 → 刚性成本必须预算化。
+
+
+
+ ✅ CFO 行动:下月把“预算上限 + 回款节奏 + 现金红线”三件事落到制度里。
+
+
+
+
+
+
+
+
+
+
+
+
✅
+
本月最后一句大白话
+
+ 1月最关键不是“算得多细”,
+ 是先把卡卡猫中信0405的现金流稳住:
+ 预算有上限、回款有节奏、现金有红线。
+
+
+ 🧾 云消费补齐
+ 📊 费用科目化
+ 🧠 每周滚动预测
+ 🚨 异常预警
+
+
下一步迭代:把“云消费(腾讯云)”与“飞书报销/付款”补齐,形成完整月结。
+
+
+
+
+
diff --git a/03_卡木(木)/木果_项目模板/PPT制作/脚本/家里NAS对话描述PPT_毛玻璃.html b/03_卡木(木)/木果_项目模板/PPT制作/脚本/家里NAS对话描述PPT_毛玻璃.html
new file mode 100644
index 00000000..a38381c6
--- /dev/null
+++ b/03_卡木(木)/木果_项目模板/PPT制作/脚本/家里NAS对话描述PPT_毛玻璃.html
@@ -0,0 +1,423 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Conversation → SOP → Skill
+
家里 NAS 对话描述
+
DiskStation · Time Machine · 外网挂载 · 自动沉淀
+
+ 💧 诊断
+ 🪙 运维
+ 🌱 产出
+ 🔥 复盘
+
+
+
+
+
+
+
What Happened
+
+
+
+
+ ⏱️
+ 对话触发
+
+
+
① 时间机器:红点 + 等待首次备份
+
② 弹窗:未识别备份磁盘(DiskStation.local)
+
③ 诉求:外网挂载 1TB 共享到 Finder「位置」
+
+
+
+
关键目标:能自动就自动;做不了就落材料;最后沉淀到 Skill,可复用。
+
+
+
+
+
+
+
+
+
+
+
+
Assets Map
+
+
+
+ 🗺️
+ 公司 / 家里 两套 NAS
+
+
+
+
CKB NAS
+
192.168.1.201
+
外网:open.quwanzhi.com
+
Gitea::3000(已穿透)
+
+
+
家里 DiskStation
+
192.168.110.29
+
外网:opennas2.quwanzhi.com
+
Time Machine:共享
+
+
+
+
对话中所有“定位/修复/挂载”,都先区分:110 网段=家里;1 网段=公司。
+
+
+
+
+
+
+
+
+
+
+
+
Flowchart
+
+
+
+
+ 🔁
+ 从对话 → SOP → Skill
+
+
+ 这页是“对话行动链”:诊断 → 修复 → 验证 → 沉淀。后续同类问题直接复用。
+
+
+
+
产出物:检测脚本、排查文档、Skill 小节、外网挂载脚本、复盘规则。
+
+
+
+
+
+
+
+
+
+
+
+
Deliverables
+
+
+
+ 🧰
+ 可复用资产
+
+
+
① 检测脚本:time_machine_diskstation_auto.sh
+
② 排查文档:Time_Machine_DiskStation_错误排查.md
+
③ Skill:群晖NAS管理(含 Time Machine 小节)
+
④ 外网挂载:mount_diskstation_1tb.sh + 参考资料
+
⑤ 复盘规则:目标每句≤30字+必须含%
+
+
+
+
+
+
+
+
+
+
+
+
Home NAS Snapshot
+
+
+
+ 🪙
+ 资产摘要
+
+
+
+
开放端口:22 / 80 / 443 / 139 / 445 / 5000 / 5001;对外域名:opennas2.quwanzhi.com。
+
+
+
+
+
+
+
+
+
+
+
+
Next Actions
+
+
+
+ ▶️
+ 按顺序执行
+
+
+
① Time Machine:移除“共享”→重新添加
+
② 外网挂载:frpc 加 SMB 4452 → Finder ⌘K
+
③ 自动化:遇到同类问题直接跑检测脚本
+
④ 复盘:对话结尾统一按复盘块输出
+
+
+
提示:Finder 复制文件会显示速率;侧栏固定:挂载后拖到「位置」。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🏁
+ 结尾
+
+
把一次对话变成可复用系统
+
诊断 → 修复 → 验证 → 文档/Skill 沉淀
+
+
PPT 用途:对外汇报、团队交接、下次直接照流程做,不再重复沟通成本。
+
+
+
+
+
diff --git a/03_卡木(木)/木果_项目模板/PPT制作/脚本/毛玻璃截图转PPT.py b/03_卡木(木)/木果_项目模板/PPT制作/脚本/毛玻璃截图转PPT.py
index f29d0c20..4e8fe283 100644
--- a/03_卡木(木)/木果_项目模板/PPT制作/脚本/毛玻璃截图转PPT.py
+++ b/03_卡木(木)/木果_项目模板/PPT制作/脚本/毛玻璃截图转PPT.py
@@ -63,19 +63,34 @@ def build_ppt(imgs, out_ppt):
def main():
import argparse
ap = argparse.ArgumentParser()
- ap.add_argument("--html", default="复盘", choices=["复盘", "卡若人设", "纳瓦尔访谈", "天恩乖乖", "今日日志总结"])
+ ap.add_argument("--html", default="复盘", choices=["复盘", "卡若人设", "纳瓦尔访谈", "天恩乖乖", "今日日志总结", "家里NAS对话描述", "公司财务分析", "公司财务月报_2026-01"])
args = ap.parse_args()
if args.html == "卡若人设":
html = BASE / "卡若人设PPT_毛玻璃.html"
out_slides = OUT_ROOT / "卡若人设_毛玻璃_slides"
out_ppt = OUT_ROOT / "卡若人设介绍_毛玻璃.pptx"
max_slides = 5
+ elif args.html == "家里NAS对话描述":
+ html = BASE / "家里NAS对话描述PPT_毛玻璃.html"
+ out_slides = OUT_ROOT / "家里NAS对话描述_毛玻璃_slides"
+ out_ppt = OUT_ROOT / "家里NAS_对话描述_毛玻璃.pptx"
+ max_slides = 8
elif args.html == "纳瓦尔访谈":
html = BASE / "纳瓦尔访谈PPT_毛玻璃.html"
# v2:扩展页数(含方法/问答/流程图/行动清单),避免覆盖旧版
out_slides = OUT_ROOT / "纳瓦尔访谈_毛玻璃_slides_v2"
out_ppt = OUT_ROOT / "纳瓦尔访谈_读书笔记_毛玻璃_v2.pptx"
max_slides = 15
+ elif args.html == "公司财务分析":
+ html = BASE / "公司财务分析PPT_毛玻璃.html"
+ out_slides = OUT_ROOT / "公司财务分析_毛玻璃_slides"
+ out_ppt = OUT_ROOT / "公司财务_多维分析_CFO大白话_毛玻璃.pptx"
+ max_slides = 12
+ elif args.html == "公司财务月报_2026-01":
+ html = BASE / "公司财务月报_2026年1月PPT_毛玻璃.html"
+ out_slides = OUT_ROOT / "公司财务月报_2026-01_毛玻璃_slides"
+ out_ppt = OUT_ROOT / "公司财务月报_2026年1月_CFO大白话_毛玻璃.pptx"
+ max_slides = 10
elif args.html == "天恩乖乖":
html = BASE / "天恩乖乖PPT_毛玻璃.html"
out_slides = TIANEN_DIR / "乖乖_毛玻璃_slides"
diff --git a/运营中枢/scripts/karuo_ai_gateway/README.md b/运营中枢/scripts/karuo_ai_gateway/README.md
index 7e48584b..b8332a32 100644
--- a/运营中枢/scripts/karuo_ai_gateway/README.md
+++ b/运营中枢/scripts/karuo_ai_gateway/README.md
@@ -15,6 +15,62 @@ uvicorn main:app --host 0.0.0.0 --port 8000
- `OPENAI_API_KEY`:OpenAI 或兼容 API 的密钥,配置后使用真实 LLM 生成回复。
- `OPENAI_API_BASE`:兼容接口地址,默认 `https://api.openai.com/v1`。
- `OPENAI_MODEL`:模型名,默认 `gpt-4o-mini`。
+- `KARUO_GATEWAY_CONFIG`:网关配置路径(默认 `config/gateway.yaml`)。
+- `KARUO_GATEWAY_SALT`:部门 Key 的 salt(用于 sha256 校验;不写入仓库)。
+
+## 部门/科室鉴权与白名单(推荐启用)
+
+网关支持“每部门一个 Key + 技能白名单”,用于:
+
+- 科室/部门直接调用接口,不互相影响
+- 外网暴露时避免“全能力裸奔”
+- 能按部门做限流/审计日志
+
+### 1) 准备配置文件
+
+从示例复制一份(`gateway.yaml` 建议不要提交到仓库):
+
+- `config/gateway.example.yaml` → `config/gateway.yaml`
+
+### 2) 准备 salt(只在环境变量)
+
+在运行环境里设置:
+
+```bash
+export KARUO_GATEWAY_SALT="一个足够长的随机字符串"
+```
+
+### 3) 生成部门 Key 与 hash
+
+```bash
+python tools/generate_dept_key.py --tenant-id finance --tenant-name "财务科"
+```
+
+把输出里的 `api_key_sha256` 写入 `config/gateway.yaml` 对应 tenant;明文 `dept_key` 只出现一次,保存到部门系统的安全配置里。
+
+### 4) 调用方式
+
+#### 4.1 /v1/chat
+
+```bash
+curl -s -X POST "http://127.0.0.1:8000/v1/chat" \
+ -H "Content-Type: application/json" \
+ -H "X-Karuo-Api-Key: " \
+ -d '{"prompt":"你的问题"}'
+```
+
+#### 4.2 /v1/skills(部门自查)
+
+```bash
+curl -s "http://127.0.0.1:8000/v1/skills" \
+ -H "X-Karuo-Api-Key: "
+```
+
+#### 4.3 /v1/health
+
+```bash
+curl -s "http://127.0.0.1:8000/v1/health"
+```
## 外网暴露
@@ -28,6 +84,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000
```bash
curl -s -X POST "https://YOUR_DOMAIN/v1/chat" \
-H "Content-Type: application/json" \
+ -H "X-Karuo-Api-Key: " \
-d '{"prompt":"你的问题"}' | jq -r '.reply'
```
diff --git a/运营中枢/scripts/karuo_ai_gateway/config/gateway.example.yaml b/运营中枢/scripts/karuo_ai_gateway/config/gateway.example.yaml
new file mode 100644
index 00000000..d007260e
--- /dev/null
+++ b/运营中枢/scripts/karuo_ai_gateway/config/gateway.example.yaml
@@ -0,0 +1,40 @@
+version: 1
+
+auth:
+ # 外部调用请求头名:每个部门一个 Key
+ header_name: X-Karuo-Api-Key
+ # 只存 hash,不存明文 key;salt 从环境变量读取
+ salt_env: KARUO_GATEWAY_SALT
+
+tenants:
+ - id: finance
+ name: 财务科
+ # sha256(dept_key + salt) 的 hex;用 tools/generate_dept_key.py 生成
+ api_key_sha256: "REPLACE_ME"
+ # 允许调用的技能:支持填技能ID(如 E05a)或 SKILL 路径(如 05_卡土(土)/.../SKILL.md)
+ allowed_skills:
+ - E05a
+ - M07
+ limits:
+ rpm: 60
+ max_prompt_chars: 12000
+
+skills:
+ registry_path: SKILL_REGISTRY.md
+ match_strategy: trigger_contains
+ # 未匹配到技能时的策略:deny | allow_general
+ on_no_match: deny
+
+llm:
+ provider: openai_compatible
+ api_key_env: OPENAI_API_KEY
+ api_base_env: OPENAI_API_BASE
+ model_env: OPENAI_MODEL
+ timeout_seconds: 60
+ max_tokens: 2000
+
+logging:
+ enabled: true
+ # 既可填绝对路径,也可填相对仓库根目录的路径
+ path: 运营中枢/工作台/karuo_ai_gateway_access.jsonl
+ log_request_body: false
diff --git a/运营中枢/scripts/karuo_ai_gateway/main.py b/运营中枢/scripts/karuo_ai_gateway/main.py
index e5c35a51..b4c8ecad 100644
--- a/运营中枢/scripts/karuo_ai_gateway/main.py
+++ b/运营中枢/scripts/karuo_ai_gateway/main.py
@@ -5,8 +5,14 @@
from pathlib import Path
import os
import re
-from typing import Tuple
-from fastapi import FastAPI
+import time
+import json
+import hashlib
+import hmac
+from typing import Any, Dict, List, Optional, Tuple
+
+import yaml
+from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
@@ -15,6 +21,103 @@ REPO_ROOT = Path(__file__).resolve().parents[3]
app = FastAPI(title="卡若AI 网关", version="1.0")
+DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent / "config" / "gateway.yaml"
+
+
+def _is_abs_path(p: str) -> bool:
+ try:
+ return Path(p).is_absolute()
+ except Exception:
+ return False
+
+
+def _resolve_path(p: str) -> Path:
+ if _is_abs_path(p):
+ return Path(p)
+ return REPO_ROOT / p
+
+
+def _read_yaml(path: Path) -> Dict[str, Any]:
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
+
+
+def load_config() -> Dict[str, Any]:
+ """
+ 读取网关配置(多租户/鉴权/白名单等)。
+ - 默认路径:config/gateway.yaml(不入库;你可以从 gateway.example.yaml 复制一份)
+ - 也支持通过环境变量 KARUO_GATEWAY_CONFIG 指定绝对/相对路径
+ """
+ p = os.environ.get("KARUO_GATEWAY_CONFIG", "").strip()
+ cfg_path = _resolve_path(p) if p else DEFAULT_CONFIG_PATH
+ if not cfg_path.exists():
+ return {}
+ try:
+ return _read_yaml(cfg_path)
+ except Exception:
+ # 配置读失败时不要“悄悄放行”,避免外网误用
+ raise
+
+
+def _sha256_hex(s: str) -> str:
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
+
+
+def _get_salt(cfg: Dict[str, Any]) -> str:
+ auth = (cfg or {}).get("auth") or {}
+ salt_env = auth.get("salt_env", "KARUO_GATEWAY_SALT")
+ return os.environ.get(salt_env, "")
+
+
+def _auth_header_name(cfg: Dict[str, Any]) -> str:
+ auth = (cfg or {}).get("auth") or {}
+ return auth.get("header_name", "X-Karuo-Api-Key")
+
+
+def _tenant_by_key(cfg: Dict[str, Any], api_key_plain: str) -> Optional[Dict[str, Any]]:
+ tenants = (cfg or {}).get("tenants") or []
+ if not api_key_plain:
+ return None
+ salt = _get_salt(cfg)
+ if not salt:
+ return None
+ key_hash = _sha256_hex(api_key_plain + salt)
+ for t in tenants:
+ if not isinstance(t, dict):
+ continue
+ stored = str(t.get("api_key_sha256", "")).strip()
+ if stored and hmac.compare_digest(stored, key_hash):
+ return t
+ return None
+
+
+def _rpm_allow(tenant_id: str, rpm: int) -> bool:
+ """
+ 极简内存限流(单进程);够用就行。
+ - 生产建议用 Nginx/网关层限流
+ """
+ if rpm <= 0:
+ return True
+ now = time.time()
+ window = int(now // 60)
+ key = f"{tenant_id}:{window}"
+ bucket = app.state.__dict__.setdefault("_rpm_bucket", {})
+ cnt = bucket.get(key, 0) + 1
+ bucket[key] = cnt
+ return cnt <= rpm
+
+
+def _log_access(cfg: Dict[str, Any], record: Dict[str, Any]) -> None:
+ logging_cfg = (cfg or {}).get("logging") or {}
+ if not logging_cfg.get("enabled", False):
+ return
+ path_raw = str(logging_cfg.get("path", "")).strip()
+ if not path_raw:
+ return
+ p = _resolve_path(path_raw)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ with p.open("a", encoding="utf-8") as f:
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
+
def load_bootstrap() -> str:
p = REPO_ROOT / "BOOTSTRAP.md"
@@ -24,14 +127,31 @@ def load_bootstrap() -> str:
def load_registry() -> str:
- p = REPO_ROOT / "SKILL_REGISTRY.md"
+ cfg = load_config()
+ skills_cfg = (cfg or {}).get("skills") or {}
+ reg_path = skills_cfg.get("registry_path", "SKILL_REGISTRY.md")
+ p = _resolve_path(reg_path)
if p.exists():
return p.read_text(encoding="utf-8")
return "技能注册表未找到。"
-def match_skill(prompt: str) -> Tuple[str, str]:
- """根据 prompt 在 SKILL_REGISTRY 中匹配技能,返回 (技能名, 路径)。"""
+def _normalize_trigger_token(t: str) -> str:
+ t = t.strip()
+ t = t.replace("**", "").replace("*", "")
+ t = t.replace("`", "")
+ return t.strip()
+
+
+def _split_triggers(triggers: str) -> List[str]:
+ s = triggers or ""
+ for ch in ["、", ",", ",", ";", ";", "|", "/"]:
+ s = s.replace(ch, " ")
+ return [tok for tok in (_normalize_trigger_token(x) for x in s.split()) if tok]
+
+
+def match_skill(prompt: str, cfg: Optional[Dict[str, Any]] = None) -> Tuple[str, str, str]:
+ """根据 prompt 在 SKILL_REGISTRY 中匹配技能,返回 (技能ID, 技能名, 路径)。"""
text = load_registry()
lines = text.split("\n")
for line in lines:
@@ -39,13 +159,21 @@ def match_skill(prompt: str) -> Tuple[str, str]:
continue
parts = [p.strip() for p in line.split("|")]
# 表列:| # | 技能 | 成员 | 触发词 | SKILL 路径 | 一句话 |
- if len(parts) < 6:
+ if len(parts) < 7:
continue
- skill_name, triggers, path = parts[2], parts[4], parts[5].strip("`")
- for t in triggers.replace("、", " ").split():
+ skill_id = parts[1]
+ skill_name = parts[2]
+ triggers = parts[4]
+ path = parts[5].strip("`")
+ for t in _split_triggers(triggers):
if t and t in prompt:
- return skill_name, path
- return "通用", "总索引.md"
+ return skill_id, skill_name, path
+
+ skills_cfg = (cfg or load_config() or {}).get("skills") or {}
+ on_no_match = skills_cfg.get("on_no_match", "allow_general")
+ if on_no_match == "deny":
+ return "", "", ""
+ return "GENERAL", "通用", "总索引.md"
class ChatRequest(BaseModel):
@@ -54,11 +182,18 @@ class ChatRequest(BaseModel):
class ChatResponse(BaseModel):
reply: str
+ tenant_id: str = ""
+ tenant_name: str = ""
+ skill_id: str = ""
matched_skill: str
skill_path: str
-def build_reply_with_llm(prompt: str, matched_skill: str, skill_path: str) -> str:
+def _llm_settings(cfg: Dict[str, Any]) -> Dict[str, Any]:
+ return (cfg or {}).get("llm") or {}
+
+
+def build_reply_with_llm(prompt: str, cfg: Dict[str, Any], matched_skill: str, skill_path: str) -> str:
"""调用 LLM 生成回复(OpenAI 兼容)。未配置则返回模板回复。"""
bootstrap = load_bootstrap()
system = (
@@ -66,8 +201,9 @@ def build_reply_with_llm(prompt: str, matched_skill: str, skill_path: str) -> st
f"当前匹配技能:{matched_skill},路径:{skill_path}。"
"先简短思考并输出,再给执行要点,最后必须带「[卡若复盘]」块(含目标·结果·达成率、过程 1 2 3、反思、总结、下一步)。"
)
- api_key = os.environ.get("OPENAI_API_KEY")
- base_url = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
+ llm_cfg = _llm_settings(cfg)
+ api_key = os.environ.get(llm_cfg.get("api_key_env", "OPENAI_API_KEY"))
+ base_url = os.environ.get(llm_cfg.get("api_base_env", "OPENAI_API_BASE"), "https://api.openai.com/v1")
if api_key:
try:
import httpx
@@ -75,11 +211,11 @@ def build_reply_with_llm(prompt: str, matched_skill: str, skill_path: str) -> st
f"{base_url.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={
- "model": os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
+ "model": os.environ.get(llm_cfg.get("model_env", "OPENAI_MODEL"), "gpt-4o-mini"),
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
- "max_tokens": 2000,
+ "max_tokens": int(llm_cfg.get("max_tokens", 2000)),
},
- timeout=60.0,
+ timeout=float(llm_cfg.get("timeout_seconds", 60)),
)
if r.status_code == 200:
data = r.json()
@@ -131,11 +267,101 @@ def index():
@app.post("/v1/chat", response_model=ChatResponse)
-def chat(req: ChatRequest):
+async def chat(req: ChatRequest, request: Request):
"""外部调用入口:传入 prompt,返回按卡若AI 流程生成的 reply。"""
- matched_skill, skill_path = match_skill(req.prompt)
- reply = build_reply_with_llm(req.prompt, matched_skill, skill_path)
- return ChatResponse(reply=reply, matched_skill=matched_skill, skill_path=skill_path)
+ cfg = load_config()
+
+ # 1) 鉴权(如果有配置文件就强制开启)
+ tenant: Optional[Dict[str, Any]] = None
+ if cfg:
+ header_name = _auth_header_name(cfg)
+ api_key = request.headers.get(header_name, "")
+ tenant = _tenant_by_key(cfg, api_key)
+ if not tenant:
+ raise HTTPException(status_code=401, detail="invalid api key")
+
+ tenant_id = str((tenant or {}).get("id", "")).strip()
+ tenant_name = str((tenant or {}).get("name", "")).strip()
+
+ # 2) 限流/输入限制(可选)
+ limits = (tenant or {}).get("limits") or {}
+ max_prompt_chars = int(limits.get("max_prompt_chars", 0) or 0)
+ if max_prompt_chars and len(req.prompt) > max_prompt_chars:
+ raise HTTPException(status_code=413, detail="prompt too large")
+
+ rpm = int(limits.get("rpm", 0) or 0)
+ if tenant_id and rpm and not _rpm_allow(tenant_id, rpm):
+ raise HTTPException(status_code=429, detail="rate limit exceeded")
+
+ # 3) 技能匹配 + 白名单校验
+ skill_id, matched_skill, skill_path = match_skill(req.prompt, cfg=cfg)
+ if cfg and not (skill_id and matched_skill and skill_path):
+ raise HTTPException(status_code=404, detail="no skill matched")
+
+ if tenant:
+ allowed = (tenant.get("allowed_skills") or []) if isinstance(tenant, dict) else []
+ allowed = [str(x).strip() for x in allowed if str(x).strip()]
+ if allowed:
+ # 同时支持“技能ID白名单”和“SKILL路径白名单”
+ if (skill_id not in allowed) and (skill_path not in allowed):
+ raise HTTPException(status_code=403, detail="skill not allowed for tenant")
+
+ reply = build_reply_with_llm(req.prompt, cfg, matched_skill, skill_path)
+
+ # 4) 访问日志(默认不落 prompt 内容)
+ logging_cfg = (cfg or {}).get("logging") or {}
+ record: Dict[str, Any] = {
+ "ts": int(time.time()),
+ "tenant_id": tenant_id,
+ "tenant_name": tenant_name,
+ "skill_id": skill_id,
+ "matched_skill": matched_skill,
+ "skill_path": skill_path,
+ "client": request.client.host if request.client else "",
+ "ua": request.headers.get("user-agent", ""),
+ }
+ if bool(logging_cfg.get("log_request_body", False)):
+ record["prompt"] = req.prompt
+ _log_access(cfg, record)
+
+ return ChatResponse(
+ reply=reply,
+ tenant_id=tenant_id,
+ tenant_name=tenant_name,
+ skill_id=skill_id,
+ matched_skill=matched_skill,
+ skill_path=skill_path,
+ )
+
+
+@app.get("/v1/health")
+def health():
+ return {"ok": True}
+
+
+@app.get("/v1/skills")
+def allowed_skills(request: Request):
+ """
+ 返回该 tenant 允许的技能清单(需要 key)。
+ 用途:部门侧自查权限/联调。
+ """
+ cfg = load_config()
+ if not cfg:
+ return {"tenants_enabled": False, "allowed_skills": []}
+ header_name = _auth_header_name(cfg)
+ api_key = request.headers.get(header_name, "")
+ tenant = _tenant_by_key(cfg, api_key)
+ if not tenant:
+ raise HTTPException(status_code=401, detail="invalid api key")
+ allowed = tenant.get("allowed_skills") or []
+ allowed = [str(x).strip() for x in allowed if str(x).strip()]
+ return {
+ "tenants_enabled": True,
+ "tenant_id": str(tenant.get("id", "")).strip(),
+ "tenant_name": str(tenant.get("name", "")).strip(),
+ "allowed_skills": allowed,
+ "header_name": header_name,
+ }
if __name__ == "__main__":
diff --git a/运营中枢/scripts/karuo_ai_gateway/requirements.txt b/运营中枢/scripts/karuo_ai_gateway/requirements.txt
index 66da2380..c314dea7 100644
--- a/运营中枢/scripts/karuo_ai_gateway/requirements.txt
+++ b/运营中枢/scripts/karuo_ai_gateway/requirements.txt
@@ -2,3 +2,4 @@ fastapi>=0.100.0
uvicorn>=0.22.0
httpx>=0.24.0
pydantic>=2.0
+PyYAML>=6.0
diff --git a/运营中枢/scripts/karuo_ai_gateway/tools/generate_dept_key.py b/运营中枢/scripts/karuo_ai_gateway/tools/generate_dept_key.py
new file mode 100644
index 00000000..d863907c
--- /dev/null
+++ b/运营中枢/scripts/karuo_ai_gateway/tools/generate_dept_key.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+"""
+生成“部门/科室”的 API Key(明文只输出一次)并给出写入 gateway.yaml 的 sha256。
+
+用法:
+ export KARUO_GATEWAY_SALT="your-long-random-salt"
+ python tools/generate_dept_key.py --tenant-id finance --tenant-name "财务科"
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import os
+import secrets
+import sys
+
+
+def sha256_hex(s: str) -> str:
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--tenant-id", required=True, help="tenant id, e.g. finance")
+ parser.add_argument("--tenant-name", default="", help='tenant name, e.g. "财务科"')
+ parser.add_argument(
+ "--key",
+ default="",
+ help="可选:自定义明文 key(不建议)。为空则自动生成。",
+ )
+ parser.add_argument(
+ "--salt-env",
+ default="KARUO_GATEWAY_SALT",
+ help="salt 的环境变量名,默认 KARUO_GATEWAY_SALT",
+ )
+ args = parser.parse_args()
+
+ salt = os.environ.get(args.salt_env, "")
+ if not salt:
+ print(f"[ERROR] 未读取到 salt 环境变量:{args.salt_env}", file=sys.stderr)
+ print("建议:export KARUO_GATEWAY_SALT=\"一个足够长的随机字符串\"", file=sys.stderr)
+ return 2
+
+ dept_key = args.key or secrets.token_urlsafe(32)
+ key_hash = sha256_hex(dept_key + salt)
+
+ # 明文 key 只输出一次;请在生成后立即给到部门系统的安全配置里。
+ print("tenant:")
+ print(f" id: {args.tenant_id}")
+ if args.tenant_name:
+ print(f" name: {args.tenant_name}")
+ print("")
+ print("dept_key (plaintext, show once):")
+ print(dept_key)
+ print("")
+ print("api_key_sha256 (write into gateway.yaml):")
+ print(key_hash)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
diff --git a/运营中枢/参考资料/卡若AI外网化与外部调用方案.md b/运营中枢/参考资料/卡若AI外网化与外部调用方案.md
index fb4f9d54..d988fb68 100644
--- a/运营中枢/参考资料/卡若AI外网化与外部调用方案.md
+++ b/运营中枢/参考资料/卡若AI外网化与外部调用方案.md
@@ -82,6 +82,42 @@ uvicorn main:app --host 0.0.0.0 --port 8000
---
+## 四点五、接口配置化(科室/部门可复制)
+
+> 目标:让以后任何科室/部门/合作方都能“拿到一套配置 + 一个 key”,直接调用卡若AI 网关,不需要改代码。
+
+### 你需要提前准备什么(一次性)
+
+1. **一个 salt**(只放环境变量,不写入仓库):`KARUO_GATEWAY_SALT`
+2. (可选)如果要真实 LLM 输出:`OPENAI_API_KEY`(以及 `OPENAI_API_BASE`、`OPENAI_MODEL`)
+3. 外网场景:域名/反代已就绪(宝塔/Nginx)或 ngrok 临时暴露
+
+### 配置文件在哪里
+
+- 示例:`运营中枢/scripts/karuo_ai_gateway/config/gateway.example.yaml`
+- 实际:`运营中枢/scripts/karuo_ai_gateway/config/gateway.yaml`(建议不提交到仓库)
+- 也可用环境变量指定:`KARUO_GATEWAY_CONFIG=/path/to/gateway.yaml`
+
+### 新增一个科室/部门(标准步骤)
+
+1. 设置 salt(运行环境):
+ - `export KARUO_GATEWAY_SALT="一个足够长的随机字符串"`
+2. 生成部门 key(明文只输出一次)与 hash:
+ - `python 运营中枢/scripts/karuo_ai_gateway/tools/generate_dept_key.py --tenant-id finance --tenant-name "财务科"`
+3. 将输出的 `api_key_sha256` 写入 `config/gateway.yaml` 的对应 tenant
+4. 配置该 tenant 的 `allowed_skills`(技能白名单:支持技能ID如 `E05a`,或 SKILL 路径)
+5. 重启网关服务
+
+### 调用方式(必须带部门 key)
+
+- `POST /v1/chat`:
+ - Header:`X-Karuo-Api-Key: `
+ - Body:`{"prompt":"你的问题"}`
+- `GET /v1/skills`:部门自查当前允许技能(同样需要 key)
+- `GET /v1/health`:健康检查(无需 key)
+
+---
+
## 五、最终:执行命令与链接(给 Cursor / 其他 AI 用)
**固定域名**:`https://kr-ai.quwanzhi.com`(部署与配置见「内网穿透与域名配置_卡若AI标准方案.md」)。
diff --git a/运营中枢/工作台/gitea_push_log.md b/运营中枢/工作台/gitea_push_log.md
index 7c58d36e..15a54225 100644
--- a/运营中枢/工作台/gitea_push_log.md
+++ b/运营中枢/工作台/gitea_push_log.md
@@ -125,3 +125,4 @@
| 2026-02-24 05:49:20 | 🔄 卡若AI 同步 2026-02-24 05:49 | 更新:卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 10 个 |
| 2026-02-24 11:42:10 | 🔄 卡若AI 同步 2026-02-24 11:42 | 更新:金仓、水桥平台对接、运营中枢工作台 | 排除 >20MB: 10 个 |
| 2026-02-24 16:28:06 | 🔄 卡若AI 同步 2026-02-24 16:28 | 更新:水桥平台对接、卡木、卡土、运营中枢工作台 | 排除 >20MB: 10 个 |
+| 2026-02-24 16:49:15 | 🔄 卡若AI 同步 2026-02-24 16:49 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 10 个 |
diff --git a/运营中枢/工作台/代码管理.md b/运营中枢/工作台/代码管理.md
index 4856b237..99c6d4b3 100644
--- a/运营中枢/工作台/代码管理.md
+++ b/运营中枢/工作台/代码管理.md
@@ -128,3 +128,4 @@
| 2026-02-24 05:49:20 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 05:49 | 更新:卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 10 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
| 2026-02-24 11:42:10 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 11:42 | 更新:金仓、水桥平台对接、运营中枢工作台 | 排除 >20MB: 10 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
| 2026-02-24 16:28:06 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 16:28 | 更新:水桥平台对接、卡木、卡土、运营中枢工作台 | 排除 >20MB: 10 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
+| 2026-02-24 16:49:15 | 成功 | 成功 | 🔄 卡若AI 同步 2026-02-24 16:49 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 10 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |