feat: 新增项目标签重置接口与n8n流程

This commit is contained in:
2026-02-22 11:58:29 +08:00
parent 93c616793d
commit 285af9a039
45 changed files with 844 additions and 11506 deletions
+10 -1
View File
@@ -19,7 +19,15 @@ docs/
├── n8n/ # n8n workflow documentation
│ ├── historical-workflow-design.md
│ ├── incremental-workflow-design.md
── tag-janitor-workflow.md
── tag-janitor-workflow.json
│ ├── project-tag-reset-workflow.json
│ ├── project-tag-reset-workflow.md
│ ├── github-link-janitor-workflow.json
│ ├── github-link-janitor-workflow.md
│ ├── github-link-repair-workflow.json
│ ├── github-link-repair-workflow.md
│ ├── github-stars-refresh-workflow.json
│ └── github-stars-refresh-workflow.md
└── plans/ # Design & implementation plans
├── 2026-01-25-ai-search-system-design.md
├── 2026-01-25-ai-search-implementation.md
@@ -37,6 +45,7 @@ docs/
| `discovery-workflow.md` | Project discovery architecture |
| `plans/*.md` | Feature design & implementation specs |
| `n8n/*.md` | n8n workflow design docs |
| `n8n/project-tag-reset-workflow.json` | Project tag reset workflow (multi-AI category classification) |
## FOR AI AGENTS
+246
View File
@@ -0,0 +1,246 @@
{
"name": "Project Tag Reset - Multi AI Classifier",
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"url": "={{$env.SITE_BASE_URL}}/api/tags",
"options": {}
},
"id": "fetch-tags",
"name": "Fetch Tags",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [220, 0]
},
{
"parameters": {
"url": "={{$env.SITE_BASE_URL}}/api/projects?limit=100&page=1&sort=latest",
"options": {}
},
"id": "fetch-projects",
"name": "Fetch Projects",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [440, 0]
},
{
"parameters": {
"jsCode": "const tagsResponse = $('Fetch Tags').first().json;\nconst projectsResponse = $input.first().json;\n\nif (!tagsResponse?.success || !Array.isArray(tagsResponse?.tags)) {\n throw new Error('Failed to fetch tags from /api/tags');\n}\n\nif (!Array.isArray(projectsResponse?.projects)) {\n throw new Error('Failed to fetch projects from /api/projects');\n}\n\nif ((projectsResponse?.pagination?.total || 0) > projectsResponse.projects.length) {\n throw new Error(`Only fetched ${projectsResponse.projects.length} projects but total is ${projectsResponse.pagination.total}. Increase API limit or add pagination in workflow.`);\n}\n\nconst allowedCategories = [\n 'FIXED_PROJECT_TYPE',\n 'TECH_STACK',\n 'AI_PARADIGM',\n 'PRODUCT_FORM',\n 'DOMAIN_SCENARIO',\n];\n\nconst tagPools = Object.fromEntries(\n allowedCategories.map((category) => [\n category,\n tagsResponse.tags\n .filter((tag) => tag.category === category)\n .map((tag) => ({\n id: tag.id,\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n })),\n ])\n);\n\nreturn projectsResponse.projects.map((project) => ({\n json: {\n projectSlug: project.slug,\n projectName: project.name,\n projectNameEn: project.nameEn || '',\n projectDescription: project.description,\n projectDescriptionEn: project.descriptionEn || '',\n currentTags: Array.isArray(project.tags)\n ? project.tags.map((tag) => ({\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n }))\n : [],\n tagPools,\n },\n}));"
},
"id": "prepare-classification-items",
"name": "Prepare Classification Items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [660, 0]
},
{
"parameters": {
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "You classify projects into FIXED_PROJECT_TYPE. Return STRICT JSON only."
},
{
"role": "user",
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- nameEn: {{$json.projectNameEn}}\\n- description: {{$json.projectDescription}}\\n- descriptionEn: {{$json.projectDescriptionEn}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (FIXED_PROJECT_TYPE):\\n{{JSON.stringify($json.tagPools.FIXED_PROJECT_TYPE)}}\\n\\nTask:\\n- Choose exactly ONE best slug from candidate pool.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"one-slug\"]\\n}"
}
]
},
"options": {
"temperature": 0.1
}
},
"id": "ai-fixed-project-type",
"name": "AI Fixed Project Type",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [880, 0]
},
{
"parameters": {
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "You classify DOMAIN_SCENARIO tags. Return STRICT JSON only."
},
{
"role": "user",
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (DOMAIN_SCENARIO):\\n{{JSON.stringify($json.tagPools.DOMAIN_SCENARIO)}}\\n\\nTask:\\n- Choose 1 to 3 slugs from candidate pool.\\n- Keep only the most representative domains for this project.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
}
]
},
"options": {
"temperature": 0.1
}
},
"id": "ai-domain-scenario",
"name": "AI Domain Scenario",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [1100, 0]
},
{
"parameters": {
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "You classify PRODUCT_FORM tags. Return STRICT JSON only."
},
{
"role": "user",
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (PRODUCT_FORM):\\n{{JSON.stringify($json.tagPools.PRODUCT_FORM)}}\\n\\nTask:\\n- Choose 1 to 3 slugs from candidate pool that best represent product form.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
}
]
},
"options": {
"temperature": 0.1
}
},
"id": "ai-product-form",
"name": "AI Product Form",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [1320, 0]
},
{
"parameters": {
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "You classify TECH_STACK tags. Return STRICT JSON only."
},
{
"role": "user",
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (TECH_STACK):\\n{{JSON.stringify($json.tagPools.TECH_STACK)}}\\n\\nTask:\\n- Choose 1 to 8 slugs from candidate pool for the main technologies used by this project.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
}
]
},
"options": {
"temperature": 0.1
}
},
"id": "ai-tech-stack",
"name": "AI Tech Stack",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [1540, 0]
},
{
"parameters": {
"model": "gpt-4o-mini",
"messages": {
"values": [
{
"role": "system",
"content": "You classify AI_PARADIGM tags. Return STRICT JSON only."
},
{
"role": "user",
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (AI_PARADIGM):\\n{{JSON.stringify($json.tagPools.AI_PARADIGM)}}\\n\\nTask:\\n- Choose 0 to 5 slugs from candidate pool for AI paradigm.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
}
]
},
"options": {
"temperature": 0.1
}
},
"id": "ai-ai-paradigm",
"name": "AI AI Paradigm",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [1760, 0]
},
{
"parameters": {
"jsCode": "function parseNodeJson(nodeJson) {\n const content = nodeJson?.message?.content || nodeJson?.text || nodeJson?.response || nodeJson;\n\n if (typeof content === 'string') {\n const trimmed = content.trim();\n try {\n return JSON.parse(trimmed);\n } catch {\n const jsonMatch = trimmed.match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n return JSON.parse(jsonMatch[0]);\n }\n return { selected: [] };\n }\n }\n\n if (typeof content === 'object' && content !== null) {\n return content;\n }\n\n return { selected: [] };\n}\n\nfunction normalizeSelected(parsed, pool, minCount, maxCount) {\n const poolSlugs = pool.map((tag) => String(tag.slug).toLowerCase());\n const allowed = new Set(poolSlugs);\n const selectedRaw = Array.isArray(parsed?.selected) ? parsed.selected : [];\n const selected = [];\n\n for (const slug of selectedRaw) {\n const normalizedSlug = String(slug || '').trim().toLowerCase();\n if (!normalizedSlug || !allowed.has(normalizedSlug) || selected.includes(normalizedSlug)) {\n continue;\n }\n selected.push(normalizedSlug);\n if (selected.length >= maxCount) {\n break;\n }\n }\n\n if (selected.length < minCount) {\n return poolSlugs.slice(0, minCount);\n }\n return selected;\n}\n\nconst preparedItems = $items('Prepare Classification Items', 0);\nconst fixedItems = $items('AI Fixed Project Type', 0);\nconst domainItems = $items('AI Domain Scenario', 0);\nconst productItems = $items('AI Product Form', 0);\nconst techItems = $items('AI Tech Stack', 0);\nconst paradigmItems = $items('AI AI Paradigm', 0);\n\nif (\n preparedItems.length !== fixedItems.length ||\n preparedItems.length !== domainItems.length ||\n preparedItems.length !== productItems.length ||\n preparedItems.length !== techItems.length ||\n preparedItems.length !== paradigmItems.length\n) {\n throw new Error('AI node output item count mismatch');\n}\n\nconst output = [];\nfor (let i = 0; i < preparedItems.length; i++) {\n const base = preparedItems[i].json;\n const pools = base.tagPools;\n\n const fixed = normalizeSelected(\n parseNodeJson(fixedItems[i].json),\n pools.FIXED_PROJECT_TYPE,\n 1,\n 1\n );\n const domains = normalizeSelected(\n parseNodeJson(domainItems[i].json),\n pools.DOMAIN_SCENARIO,\n 1,\n 3\n );\n const productForms = normalizeSelected(\n parseNodeJson(productItems[i].json),\n pools.PRODUCT_FORM,\n 1,\n 3\n );\n const techStack = normalizeSelected(\n parseNodeJson(techItems[i].json),\n pools.TECH_STACK,\n 1,\n 8\n );\n const paradigms = normalizeSelected(\n parseNodeJson(paradigmItems[i].json),\n pools.AI_PARADIGM,\n 0,\n 5\n );\n\n output.push({\n json: {\n projectSlug: base.projectSlug,\n selectedTagSlugsByCategory: {\n FIXED_PROJECT_TYPE: fixed,\n TECH_STACK: techStack,\n AI_PARADIGM: paradigms,\n PRODUCT_FORM: productForms,\n DOMAIN_SCENARIO: domains,\n },\n },\n });\n}\n\nreturn output;"
},
"id": "build-reset-payload",
"name": "Build Reset Payload",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1980, 0]
},
{
"parameters": {
"method": "POST",
"url": "={{$env.SITE_BASE_URL}}/api/tags/reset-projects",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"apiKey\": \"{{$env.WEBHOOK_API_KEY}}\",\n \"dryRun\": false,\n \"replaceAllCategories\": true,\n \"projects\": [\n {\n \"projectSlug\": \"{{$json.projectSlug}}\",\n \"selectedTagSlugsByCategory\": {{JSON.stringify($json.selectedTagSlugsByCategory)}}\n }\n ]\n}",
"options": {}
},
"id": "reset-project-tags",
"name": "Reset Project Tags",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [2200, 0]
},
{
"parameters": {
"jsCode": "const results = $input.all().map((item) => item.json);\nconst summary = {\n totalRequests: results.length,\n successRequests: results.filter((r) => r.success === true).length,\n failedRequests: results.filter((r) => r.success !== true).length,\n timestamp: new Date().toISOString(),\n};\n\nreturn [{ json: { summary, results } }];"
},
"id": "summarize-results",
"name": "Summarize Results",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [2420, 0]
}
],
"connections": {
"Manual Trigger": {
"main": [[{ "node": "Fetch Tags", "type": "main", "index": 0 }]]
},
"Fetch Tags": {
"main": [[{ "node": "Fetch Projects", "type": "main", "index": 0 }]]
},
"Fetch Projects": {
"main": [[{ "node": "Prepare Classification Items", "type": "main", "index": 0 }]]
},
"Prepare Classification Items": {
"main": [[{ "node": "AI Fixed Project Type", "type": "main", "index": 0 }]]
},
"AI Fixed Project Type": {
"main": [[{ "node": "AI Domain Scenario", "type": "main", "index": 0 }]]
},
"AI Domain Scenario": {
"main": [[{ "node": "AI Product Form", "type": "main", "index": 0 }]]
},
"AI Product Form": {
"main": [[{ "node": "AI Tech Stack", "type": "main", "index": 0 }]]
},
"AI Tech Stack": {
"main": [[{ "node": "AI AI Paradigm", "type": "main", "index": 0 }]]
},
"AI AI Paradigm": {
"main": [[{ "node": "Build Reset Payload", "type": "main", "index": 0 }]]
},
"Build Reset Payload": {
"main": [[{ "node": "Reset Project Tags", "type": "main", "index": 0 }]]
},
"Reset Project Tags": {
"main": [[{ "node": "Summarize Results", "type": "main", "index": 0 }]]
}
},
"settings": {
"executionOrder": "v1"
},
"meta": {
"templateCredsSetupCompleted": true
}
}
+40
View File
@@ -0,0 +1,40 @@
# Project Tag Reset Workflow
## Goal
Reset every project's tags according to the new taxonomy, using **one dedicated AI node per tag category**:
- `FIXED_PROJECT_TYPE`
- `DOMAIN_SCENARIO`
- `PRODUCT_FORM`
- `TECH_STACK`
- `AI_PARADIGM`
Each AI node must select only from its own category pool.
## Files
- Workflow JSON: `docs/n8n/project-tag-reset-workflow.json`
- API endpoint used by workflow: `POST /api/tags/reset-projects`
## Required Environment Variables in n8n
- `SITE_BASE_URL`: e.g. `https://your-site.com`
- `WEBHOOK_API_KEY`: same key configured on Next.js server
## Execution Flow
1. `Manual Trigger`
2. `HTTP GET /api/tags` to load full tag pool
3. `HTTP GET /api/projects?limit=100&page=1` to load projects
4. `Code` prepares per-project classification items and category pools
5. Five independent AI nodes classify by category
6. `Code` normalizes/parses AI outputs into `selectedTagSlugsByCategory`
7. `HTTP POST /api/tags/reset-projects` updates tags per project
8. `Code` summarizes success/failure counts
## Notes
- Current workflow fetches up to 100 projects in one run. If total > 100, add pagination or increase API limit strategy.
- Endpoint supports `dryRun`. You can set `"dryRun": true` first in the request node for safe validation.
- Endpoint performs category validation and rejects cross-category slug usage.