platform-assistant/AGENTS.md
Sisyphus a9ef6e952f docs: 记录品类方案 C + DeepSeek→MiniMax 迁移 + 停止 AI 回填决策
- ProductCategory 表/CRUD/AI 分类/关键词反哺完整流程
- LLM 配置 (MiniMax-M2.7, env MINIMAX_API_KEY)
- 重要决策:停止用 AI 回填历史空品类线索 (91% 已分配,剩余 68 条 + AI 新建 25 个细分类 交人工 review)
2026-07-01 11:45:36 +08:00

337 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AGENTS.md
语言:中文。所有对话、注释、文档、提交信息均使用中文。
## 🤝 协作流程标准(避免理解不一致造成返工)
**所有具体问题的修改,必须按这个流程执行:**
```
1. 用户描述需求(业务场景、看到的问题、截图、日志)
↓
2. 协作者反问 7 个技术维度(场景/触发/重要程度/数据/边界/样式/部署)
↓
3. 对齐范围:用户确认 + 协作者列技术方案
↓
4. 实施 + 验证
↓
5. 用户看效果,反馈或继续下一项
```
### 协作者必须问的 7 个技术维度
| 维度 | 问什么 |
|------|--------|
| **场景** | 哪个页面/哪个模块?用户路径是什么? |
| **触发条件** | 什么时候出现?手动还是自动?频率? |
| **重要程度** | 阻塞核心流程 / 体验优化 / 锦上添花? |
| **数据** | 影响多少数据?要不要数据迁移? |
| **边界** | 空数据/异常情况怎么处理? |
| **样式** | 改动大吗?要不要出截图对比? |
| **生产部署** | 改完要不要立即部署?还是攒一起? |
### 原则
- **不擅自开干**:未对齐范围前不做实现
- **不预设方案**:用户描述 ≠ 协作者的解决方案,先问清楚
- **不省略验证**:改完必须跑端到端测试,不只是"代码看起来对"
- **不静默改大动作**:涉及数据库结构、生产配置、依赖升级等要明确告知
### 例外(不需要问的简单场景)
- 拼写错误、明显笔误
- 单文件 typo 修复
- 用户明确说"按你判断做"
- 紧急 bug 修复(事后补问)
## 项目结构
```
/
├── apps/
│ ├── web/ # Next.js 14 App Router + Tailwind CSS 3
│ └── api/ # NestJS 10 + Prisma ORM + SQLite
├── packages/
│ └── shared/ # 共享 TS 类型(编译型,无运行时依赖)
└── scripts/
├── parse_products.py # 解析 Excel/CSV → JSON
├── tender-fetcher/ # 招标公告抓取脚本
│ ├── fetch-v1.2.js # 中电建
│ ├── fetch-xkgroup.py # 新矿集团
│ ├── fetch-cnncecp.py # 中核集团
│ └── lead_evaluation_report.py
└── direv-publish/ # direv 平台自动发布脚本(10 个 .py)
```
## 开发命令
```bash
npm run setup # 首次:npm install + db:init
npm run dev # 同时启动前端(3001)和后端(4000)
npm run dev:api # 仅后端(nest start --watch,用 dist/ 编译产物)
npm run dev:web # 仅前端
npm run db:init # prisma generate → migrate → seed
npm run db:reset # 删 db 文件后 db:init
npm run build # nest build + next build
npm run lint # eslint + next lint
npm run typecheck # tsc --noEmit 所有包(无 root tsconfig,各包独立)
npm run dev:daemon # nohup 后台启动(PID 写入 .dev-api.pid / .dev-web.pid)
npm run dev:stop # kill PID 文件中记录的进程
```
## 端口速查 (本地开发)
| 端口 | 服务 | 启动方式 |
|---|---|---|
| **3001** | 平台助手 web (Next.js) | `npm run dev:web` 或 `npm run dev` |
| **4000** | 平台助手 API (NestJS) | `npm run dev:api` 或 `npm run dev` |
| **3002** | devbox MCP server (Node.js) | `ssh devbox` + PM2, **不在 Mac 本地** |
注意:**3002 是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。
打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002)。
AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP server 地址 (填到 ConfigTab 的"endpoint" 字段), 不是 web 入口。
## 后端陷阱
- **`nest start --watch` 用 `dist/` 编译产物**:Prisma schema 变更后必须 `npm run build -w apps/api` 重建 dist,再重启。单纯 `prisma generate` 不够
- **端口 4000 残留进程**:`dev:stop` 只杀 PID 文件中记录的进程。如果有多余进程占端口,需手动 `lsof -ti:4000 | xargs kill -9`
- **Prisma 变更流程**:改 schema → `npm run db:init`(含 generate + migrate + seed)
- SQLite:`apps/api/prisma/dev.db`;种子数据(seed.ts):15 电缆型号 × 5 供应商 = 75 Product + 管理用户;`crawlerConfig` 保留 3 条(中电建 + 新矿集团 + 中核集团,中核默认 paused)
- `CrawlerService`(新增):`bid_powerchina` 类型 → `execSync('node fetch-v1.2.js')`;`bid_xkgroup` 类型 → `execSync('python3 fetch-xkgroup.py')`。脚本路径硬编码为 `scripts/tender-fetcher/`,结果从 `~/Desktop/招标公告/<date>/downloaded.json` 读取,upsert 到 Lead 表
- Lead 表 `noticeId` 从 `summary` 中提取时曾因 `substr` 字符/字节偏移混淆导致截断错误(`+8` 应为 `+6`)。提取公告 ID 统一用 `substr(summary, instr(summary, '公告ID:') + 6, 32)`
- Lead 表 `@@unique([source, noticeId])`:去重键,`CrawlerService.run()` 用 `upsert` 写入,重复则更新 summary/detailUrl
- Lead 表 `detailUrl`:独立字段,前端显示为可点击链接。脚本保存 `id` 后重构 URL(中电建用 `r.id` 而非 `systemId`;新矿需补 `&userId=guest&accountId=2066845847806639104` 认证参数)
- 后端无测试框架
## 前端陷阱
- **SPA 无路由**:`page.tsx` 用 `useState<Page>` 切换 14 个组件(含 LoginPage + Sidebar + Providers)。导航通过 `onNavigate` prop 传递 `page` 值,参数通过 props
- **API base**:`src/lib/apiBase.ts` 暴露 `API_BASE`,构建期由 `NEXT_PUBLIC_API_URL` 注入。本地开发不设环境变量时回退到 `http://localhost:4000`,生产构建为 `/api`(Nginx 反代到 4000)
- JWT 存 localStorage key `auth`,AuthProvider 启动时验证
- 3000 端口被占用,`next dev` 自动 fallback 到 3001
- 路径别名 `@/` → `src/`
- Prettier: noSemi, singleQuote, trailingComma: all, printWidth: 100
- Tailwind 设计 Token:`secondary(#0051d5)`, `border-subtle(#E2E8F0)`, `surface-container` 系
## 前端数据对接状态
| 页面 | 状态 |
|------|------|
| ComparisonPage | ✅ GET `api/comparison`,POST upload/suggestion |
| ImagePage | ✅ GET `api/images`,POST generate/parse(MiniMax 生图) |
| ProductPage | ✅ GET/POST/PATCH/DELETE `api/products` + POST upload-image |
| OverviewPage | ✅ Promise.all([GET `api/leads/stats`, GET `api/leads`]) |
| InquiryPage | ✅ GET `api/leads`,POST `api/leads/{id}/publish` |
| LeadDetailPage | ✅ GET `api/leads/{id}`,POST to-rfq/to-collab/publish/follow-up |
| RuleCenterPage | 含"采集器配置"Tab:GET/POST/PUT `api/crawlers`,POST run/pause/resume |
| ReportCenterPage | 纯 mock |
| SystemMonitorPage | 纯 mock |
| AdminPage | 已实现,5 个子区块(tenants/users/stats/configs/plans),configs 含 MCP 对接配置 |
| LoginPage | POST `api/auth/login` |
## 演示账号
`admin` / `admin123`(username + password,**不是 email**;AGENTS.md 旧版写的 `admin@platform.com` 不对,后端 LoginDto 用 username 字段)
## 数据库模型
User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Report, AuditLog, CrawlerConfig(含 keywords/categories + cron schedule), Inquiry, Product(含 supplier/imageUrl), GeneratedImage, **ProductCategory**(品类字典,AI 关键词反哺用)
## 品类方案 C(关键词优先 + AI 反哺,2026-07-01)
### 数据模型
`ProductCategory` 表:
- `name`:品类名(2-6 中文字,唯一)
- `keywords`:JSON 数组,关键字列表
- `isAIGenerated`:AI 自动创建标记(人工 review 后可改 false)
### 分类逻辑
```
1. 采集器入库 / 手动回填时,对每条线索标题做:
关键词匹配(ProductCategory.keywords contains 标题)→ 命中直接写
↓ 未命中
MiniMax API 分类 → 写品类 + 反写关键词到 ProductCategory
2. 同类标题下次直接走关键词,不再调 AI
```
### 关键代码路径
| 文件 | 说明 |
|------|------|
| `apps/api/prisma/schema.prisma` | `ProductCategory` model |
| `apps/api/src/product-category/` | CRUD + 关键词匹配 service |
| `apps/api/src/product-category/category-inference.service.ts` | AI 推断 + 反写关键词 |
| `apps/api/src/crawler/crawler.service.ts` | 采集入库用 `batchInfer()` |
| `apps/api/src/crawler/notes-extractor.service.ts` | PDF/HTML 提取要点也走 AI |
| `apps/web/src/components/InquiryPage.tsx` | 品类筛选项动态从 `/api/categories` 加载 |
### LLM 配置(DeepSeek → MiniMax 迁移)
- endpoint:`https://api.minimaxi.com/v1/chat/completions`
- model:`MiniMax-M2.7`(env `MINIMAX_MODEL` 可覆盖)
- env:`MINIMAX_API_KEY` 写入 `apps/api/.env`(生产已配)
- **注意**:M2.7 是推理模型,max_tokens 要 ≥500 才够(否则全被 <think> 吃掉)
- 生产 `.env` 同时保留 `MINIMAX_API_KEY` 和 `MINIMAX_MODEL` 两个变量
### 历史回填策略(重要:AI 不可全信)
2026-07-01 用 MiniMax 回填了 507 条历史空品类线索,结果:
- ✅ 命中:693/761(91%)已分配品类
- ⚠️ AI 自动新建 ~25 个细分类,需要人工 review 合并/删除
- ❌ 68 条空品类线索(重要数据手动指定,非重要忽略)
**决策(用户)**:停止继续调 AI 回填剩余数据。原因:
- 单条 API 推理模型耗时大(500 tokens 给思考留空间)
- AI 输出容易混入 `<think>`、`让我分析`、`**加粗**` 等解释文字
- 关键词匹配已能覆盖新采集数据,老数据人工 review 更可靠
### 后续流程
- **新采集数据**:自动走关键词匹配(15 个初始品类 + 已入库的 ~25 个细分类)
- **AdminPage → 品类管理**:用户手动 review AI 新建的细分类(合并到已有大类、删除不合理的)
- **空品类历史线索**:用户从详情面板手动指定,或忽略(过期数据)
- **新增采集场景需要新关键词**:编辑 `ProductCategory.keywords` 字段(AdminPage 或直接 SQL)
### 数据维护脚本
| 脚本 | 用途 |
|------|------|
| `scripts/backfill_category.js` | 批量回填空品类(已停止使用,仅供参考) |
| `scripts/clean_category.js` | 清理 AI 输出脏值(`<think>`、`**加粗**`、` - 描述`、解释行) |
| `scripts/deploy.sh` | 部署时自动同步上述脚本到生产 |
## 采集器关键事实
- 调度基于 `@nestjs/schedule` + `SchedulerRegistry`,`onModuleInit` 时从 DB 加载 `active` 配置注册 cron
- pause/resume/update 联动调度器的注册与注销
- `CrawlerConfig` 的 `schedule` 字段存储 cron 表达式,前端可选:每4小时、每6小时、每天早8点、每天晚7点、手动
- 运行采集器有即时反馈 Modal(请求完自动更新状态,也可"后台运行"关闭弹窗)
- 脚本路径相对于运行时工作目录,实际为 `scripts/tender-fetcher/`
- **通用化新增采集器**:PowerChina 保持特殊处理(JSON 格式不同)。其他网站只需:
1. 在 `scripts/tender-fetcher/` 放脚本(如 `fetch-xxx.py`),输出 `~/desktop/招标公告/<date>/xxx_downloaded.json`
2. 前端"采集器配置"新建记录,`type` 填 `bid_xxx`,`sourceName` 填来源显示名,`scriptPath` 填脚本文件名
3. 无需改代码,`runGenericScript()` 自动推断脚本和 JSON 文件路径
- `CrawlerConfig` 新增 `sourceName`(线索来源显示名)和 `scriptPath`(脚本文件名,可选,默认从 type 推断)
## 生产部署(腾讯云 124.222.182.242 / direv.cloud)
- **路径**:`/opt/pingtai/`(代码) + `/data/pingtai/`(持久化数据,dev.db / uploads / library / 招标公告)
- **进程**:`pm2` 托管 `pingtai-api`(NestJS 4000)+ `pingtai-web`(Next.js 3001),systemd 自启(`/etc/systemd/system/pm2-ubuntu.service`)
- **反代**:Nginx 80/443 → 3001;`/api/*` → 4000;`/uploads/*` → `/data/pingtai/uploads/` 静态直出
- **HTTPS**:Let's Encrypt(certbot),自动续期 timer 已开
- **本地重部署流程**:
1. `npm run build` 重新编译 api/web
2. `rsync` 同步 dist/.next + 源码到 `/opt/pingtai/`
3. `pm2 restart pingtai-api pingtai-web`(注意 `pm2 restart` 默认不更新环境变量,要 `--update-env`)
4. Prisma 变更后必须先 `npx prisma migrate deploy && npx prisma generate`(在 `apps/api/` 下)
- **常见操作**:
- `pm2 list` / `pm2 logs` / `pm2 restart pingtai-api`
- `tail -f /var/log/pingtai/api.out-1.log`
- `sqlite3 /data/pingtai/dev.db` 直接查 DB
- 备份:`tar czf pingtai-backup-$(date +%Y%m%d).tgz /data/pingtai/`
- **历史包袱**:服务器上旧的 5月22日 Postgres/Redis 部署已备份到 `/home/ubuntu/platform-assistant.bak-*`,旧 nginx conf 在 `/etc/nginx/conf.d/platform.conf.bak-*`,如需恢复可回退
- **采集器脚本**:`scripts/tender-fetcher/` 写出的 `~/desktop/招标公告/` 通过 symlink `~/desktop -> /data/pingtai` 落到持久化卷;symlink 必须指向 `/data/pingtai`(不是 `/data/pingtai/招标公告`),否则会双层嵌套
## CODE MAP
| Symbol | Type | Location | Role |
|--------|------|----------|------|
| `AppModule` | module | apps/api/src/app.module.ts | 根模块,导入 13 个子模块 |
| `bootstrap()` | function | apps/api/src/main.ts | NestJS 应用启动入口 |
| `page.tsx` | page | apps/web/src/app/page.tsx | SPA 根,useState 切换 14 页面 |
| `AuthProvider` | context | apps/web/src/components/AuthProvider.tsx | JWT 认证上下文 |
| `CrawlerService.run()` | method | apps/api/src/crawler/crawler.service.ts | 采集器入口,execSync 调度脚本 |
| `ImageService` | class | apps/api/src/image/image.service.ts | 三阶段生图(图库→Bing→AI) |
| `runGenericScript()` | method | apps/api/src/crawler/crawler.service.ts | 通用化采集器调度 |
| `search_image.py` | script | scripts/search_image.py | Bing 搜索 top N 候选 |
| `process_image.py` | script | scripts/process_image.py | 去水印+裁剪+白底合成 |
## product_network B2B 平台对接(2026-06-16 新增,MCP 主通道)
### 对接架构
```
平台助手 NestJS (4000)
↓ IntegrationService.callTool()
↓ MCP 协议 (Streamable HTTP)
↓ x-app-id + x-app-secret (多租户 APIKey, 替代旧的 x-api-key)
renren-mcp-server (Node.js, devbox:3002)
↓ 直调 Java 业务方法
↓
product_network 7 个 Java 服务
```
注: MCP server 端口 3002 是 **devbox 开发机** 上的端口, 不是平台助手本地端口 (3001/4000)。
### 设计原则
- **MCP 主通道**:17 个 tool(询价 5 + 商品 4 + 比价 4 + 推理 4)
- **REST 兼容层**:5 个端点(健康/兑换/订阅/订单/撤销)
- **APIKey 认证**:Header `x-api-key`,AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex)
- **公司账号代理**:每客户 1 个 `sys_user` (user_type=99)
### 关键代码路径
| 文件 | 说明 |
|------|------|
| `apps/api/prisma/schema.prisma` | `Integration` 表(MCP 连接配置) |
| `apps/api/src/integration/integration.service.ts` | MCP client 管理(connect/listTools/callTool) |
| `apps/api/src/integration/integration.controller.ts` | REST API(CRUD + test + connect) |
| `apps/api/src/integration/integration.module.ts` | NestJS 模块 |
| `apps/api/src/lead/lead.service.ts` | `publishToDirev()` 调 MCP `publish_rfq` tool |
| `apps/web/src/components/AdminPage.tsx` → `ConfigTab` | MCP 对接配置(嵌入系统配置页面) |
### 使用流程
1. 前端「运营管理 → 系统配置」页面顶部"MCP 对接配置"区域
2. 点击「新增对接」,填入:名称、MCP Server 地址、APIKey、客户编码
3. 点击「连接」验证 MCP server 可达并获取工具列表
4. 发布询价时,自动通过 MCP 调用 `publish_rfq` tool
### MCP Client 实现
```typescript
// integration.service.ts 核心逻辑
const client = new Client({ name: 'platform-assistant', version: '1.0.0' })
const transport = new StreamableHTTPClientTransport(
new URL(endpoint),
{ requestInit: { headers: { 'x-api-key': apiKey } } }
)
await client.connect(transport)
const tools = await client.listTools()
await client.callTool({ name: 'publish_rfq', arguments: { title, ... } })
```
### API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/integrations` | 列表 |
| POST | `/api/integrations` | 创建 |
| PUT | `/api/integrations/:id` | 更新 |
| DELETE | `/api/integrations/:id` | 删除 |
| POST | `/api/integrations/:id/connect` | 连接 MCP |
| POST | `/api/integrations/:id/disconnect` | 断开 |
| POST | `/api/integrations/test` | 测试连接 |
| POST | `/api/integrations/:id/call-tool` | 调用工具 |
### 注意事项
- MCP server 地址格式:`http://host:port/mcp`(Streamable HTTP transport)
- APIKey 通过 Header `x-api-key` 传递
- 客户编码通过 Header `x-customer-code` 传递(可选)
- **APIKey 加密**:AES-256-GCM,存 DB 为 base64 密文(iv12+tag16+ciphertext),读 API 时自动解密。旧明文数据读取时 fallback 不加密(兼容)
- 连接状态存储在数据库,服务重启后需重新连接
- 发布失败降级为本地模式(生成假 inquiryNo)
## ANTI-PATTERNS
- 前端不加路由库,保持 SPA useState 模式
- 不提交 secrets 到 git(JWT_SECRET 已在 .env.production,但 .gitignore 未排除)
- 不删除旧采集器 state 文件(中电建用 `systemId_招采公告` 作 key)
- 不依赖 Postgres/Redis(docker-compose.yml 是历史遗留,实际只用 SQLite)
- 不在后端代码中硬编码脚本路径以外的文件路径