Przeglądaj źródła

1. 将 prompts.txt 需求文档拆分为前后端;
2. 增加“标签系统” skill。

weisijie 4 tygodni temu
rodzic
commit
1eca780116

+ 448 - 0
frontend/prompts-backend.txt

@@ -0,0 +1,448 @@
+我设计了一个系统,内部有如下实体:
+
+1. **文章**:以文本为主体,可能包含少量表格、代码、文头文尾等内容,包含大量需提取的关键信息。一个典型的示例:
+
+    ```
+    2026年6月1日晚8点,好友们决定为王若飞(男)组织一场生日派对。这次小范围聚会共有5人参加,刘阳(女)作为总策划,除寿星和总策划外,还有李四(女)、王五(男)和赵六(男)参加。活动总预算为3000元,且已完成备案,现场安排了掷骰子、扔飞镖和打桌球等破冰游戏,确保大家玩得尽兴。
+    ```
+
+2. **关键信息定义**:使用Json定义的树状结构,每个节点以`node_label`为key,包含`metadata`字段和`subnodes`字段,`metadata`字段中包含`node_label`(取关键信息名称的拼音首字母、`node_name`(关键信息名称)、`node_type`(取值为`concept`时表示分类节点,`property`时表示属性节点)、`value_type`(仅当`node_type`为`property`时生效,取值为`integer`、`float`、`string`、`boolean`、`date_time`、`class`,其中`class`表示关键信息为复合结构,包含子属性)、`is_list`(仅当`node_type`为`property`时生效,标识关键信息是否为列表类型,即是否可以有多个实例)字段。一个典型的示例:
+
+    ```json
+    {
+      "metadata": {
+        "node_label": "PDJH",
+        "node_name": "派对计划",
+        "node_type": "concept"
+      },
+      "subnodes": {
+        "JBXX": {
+          "metadata": {
+            "node_label": "JBXX",
+            "node_name": "基本信息",
+            "node_type": "concept"
+          },
+          "subnodes": {
+            "PDMC": {
+              "metadata": {
+                "node_label": "PDMC",
+                "node_name": "派对名称",
+                "node_type": "property",
+                "value_type": "string",
+                "is_list": false
+              }
+            },
+            "PDKSSJ": {
+              "metadata": {
+                "node_label": "PDKSSJ",
+                "node_name": "派对开始时间",
+                "node_type": "property",
+                "value_type": "date_time",
+                "is_list": false
+              }
+            },
+            "PDZRS": {
+              "metadata": {
+                "node_label": "PDZRS",
+                "node_name": "派对总人数",
+                "node_type": "property",
+                "value_type": "integer",
+                "is_list": false
+              }
+            },
+            "SFBA": {
+              "metadata": {
+                "node_label": "SFBA",
+                "node_name": "是否备案",
+                "node_type": "property",
+                "value_type": "boolean",
+                "is_list": false
+              }
+            }
+          }
+        },
+        "RYXX": {
+          "metadata": {
+            "node_label": "RYXX",
+            "node_name": "人员信息",
+            "node_type": "concept"
+          },
+          "subnodes": {
+            "ZCH": {
+              "metadata": {
+                "node_label": "ZCH",
+                "node_name": "总策划",
+                "node_type": "property",
+                "value_type": "class",
+                "is_list": false
+              },
+              "subnodes": {
+                "XM": {
+                  "metadata": {
+                    "node_label": "XM",
+                    "node_name": "姓名",
+                    "node_type": "property",
+                    "value_type": "string",
+                    "is_list": false
+                  }
+                },
+                "SFZH": {
+                  "metadata": {
+                    "node_label": "SFZH",
+                    "node_name": "身份证号",
+                    "node_type": "property",
+                    "value_type": "string",
+                    "is_list": false
+                  }
+                }
+              }
+            },
+            "RYQD": {
+              "metadata": {
+                "node_label": "RYQD",
+                "node_name": "人员清单",
+                "node_type": "property",
+                "value_type": "class",
+                "is_list": true
+              },
+              "subnodes": {
+                "XM": {
+                  "metadata": {
+                    "node_label": "XM",
+                    "node_name": "姓名",
+                    "node_type": "property",
+                    "value_type": "string",
+                    "is_list": false
+                  }
+                },
+                "SFZH": {
+                  "metadata": {
+                    "node_label": "SFZH",
+                    "node_name": "身份证号",
+                    "node_type": "property",
+                    "value_type": "string",
+                    "is_list": false
+                  }
+                }
+              }
+            }
+          }
+        },
+        "HDXX": {
+          "metadata": {
+            "node_label": "HDXX",
+            "node_name": "活动信息",
+            "node_type": "concept"
+          },
+          "subnodes": {
+            "PBYXLB": {
+              "metadata": {
+                "node_label": "PBYXLB",
+                "node_name": "破冰游戏清单",
+                "node_type": "property",
+                "value_type": "string",
+                "is_list": true
+              }
+            }
+          }
+        }
+      }
+    }
+    ```
+
+3. **关键信息实例**:根据**关键信息定义**,对其中的节点进行赋值而得到的实例,以Json形式表示。其中,有如下注意事项:
+
+    1. 节点以`node_label`为key;
+    
+    2. 在**关键信息定义**中,`node_type`为`concept`的,value为Json对象,包含其子节点或子属性的值;
+
+    3. 在**关键信息定义**中,`value_type`为`integer`、`float`、`string`、`boolean`、`date_time`时,如果`is_list`为`false`,value为对应类型;如果`is_list`为`true`,value为对应类型列表。特别地,`date_time`类型的值设置为字符串,但格式固定为`XXXX-XX-XXTXX:XX:XX.XXX`;
+
+    4. 在**关键信息定义**中,`value_type`为`class`的,如果`is_list`为`false`,value为Json对象,包含其子属性的值;如果`is_list`为`true`,value为Json对象列表,每个元素包含其子属性的值。
+
+    针对**文章**示例,根据上述**关键信息定义**,生成对应的**关键信息实例**为:
+
+    ```json
+    {
+      "PDJH": {
+        "JBXX": {
+          "PDMC": "王若飞生日派对",
+          "PDKSSJ": "2026-06-01T20:00:00.000",
+          "PDZRS": 5,
+          "PDZYS": 3000.0,
+          "SFBA": true
+        },
+        "RYXX": {
+          "ZCH": {
+            "XM": "刘阳",
+            "XB": "女"
+          },
+          "RYQD": [
+            {
+              "XM": "刘阳",
+              "XB": "女"
+            },
+            {
+              "XM": "王若飞",
+              "XB": "男"
+            },
+            {
+              "XM": "李四",
+              "XB": "女"
+            },
+            {
+              "XM": "王五",
+              "XB": "男"
+            },
+            {
+              "XM": "赵六",
+              "XB": "男"
+            }
+          ]
+        },
+        "HDXX": {
+          "PBYXQD": [
+            "掷骰子",
+            "扔飞镖",
+            "打桌球"
+          ]
+        }
+      }
+    }
+    ```
+
+现在,我需要依托大模型,根据输入的**文章**和**关键信息定义**,从文章中将所有关键信息提取出来;请帮我设计一个prompt,使得大模型能够准确、高效、稳定地完成这个task。如果有必要,请使用langchain等框架,分步骤进行,以达到最好的效果。
+
+--------
+
+OK,现在,我需要将原始文本中相关的关键信息区域也标识出来。例如,针对“2026年6月1日晚8点,好友们决定为王若飞(男)组织一场生日派对,活动已完成备案。”这个文本,我希望生成类似于“{[PDJH.JBXX.PDKSSJ],2026-06-01T20:00:00.000,2026年6月1日晚8点,2026年1月1日~2026年12月31日,YYYY年M月D日(上午/下午/晚)H点[m分[s秒]]},好友们决定为{[PDJH.RYXX.RYQD[0].XM],王若飞,王若飞,2~4个汉字,不作转换}({[PDJH.RYXX.RYQD[0].XB],男,男,男/女,不作转换})组织一场生日派对,活动{[PDJH.JBXX.SFBA],true,已完成备案,已完成备案/未完成备案,true->已完成备案/false->未完成备案}”。每块关键信息区域包括{节点路径,提取值,原始文本,提取值的约束(以自然语言表示,供大模型理解),提取值到原始文本的转换规则(以自然语言表示,供大模型理解)}。请先帮我思考这个标识相关的符号体系是否合理,是否会与原文符号产生歧义,如果会,则不一定使用这套符号体系,改进该体系。
+
+--------
+
+1. 值约束来源是关键信息定义Json,部分关键信息会带一个constraints属性,里面会带{"raw_constraints":{"min_value":[0],"max_value":[100]}}、{"raw_constraints":{"enum_string":[["男", "女"]]}}这样的约束,确保将其精准地转换为自然语言,也可以在值约束里将原始的Json约束附带上。如果关键信息没有自带该属性,那么对于显而易见的关键信息约束,如“身份证号(18位数字,最后一位可以是X)”、“性别(男/女)”、“年龄(0-110)”等,你要进行推断,给出约束;对于你无法确定的约束,不要随意给出。  转换规则由你自行给出。
+2. 标注以一个独立字段输出。
+
+--------
+
+有几个提取的原则需要微调一下:
+1. 如果原文中某处同时对应关键信息定义中的多个关键信息,如“刘阳”即对应“总策划”,又对应“人员清单”中的一员,那么标注时,将多处同时标注,并在中间使用“||”分隔开,如:`{{PDJH.RYXX.ZCH.XM||刘阳||刘阳|| ||不作转换}}||{{PDJH.RYXX.RYQD[1].XM||刘阳||刘阳|| ||不作转换}}`;
+2. 如果原文中不存在关键信息实例中的某处关键信息原文,例如原文为“为王若飞(男)组织一场生日派对”,而关键信息实例中为“王若飞生日派对”,那么在原文中找到最相关的部分,如“生日派对”进行标注,并将转换规则写为“根据原文进行微调”,结果为`{{PDJH.JBXX.PDMC||王若飞生日派对||生日派对|| ||根据原文进行微调}}`;
+3. 对于时间、布尔等类型的值,你原来的做法是在代码里写死,我需要你根据原文内容,利用大模型(为确保快速处理,可不使用reasoning推理)给出明确的转换规则,例如`ISO 8601 → YYYY年M月D日(上午/下午/晚)H点[m分[s秒]],根据是否为整点,可视情省略[]中的内容`;
+4. 你现在预置了一些关键信息的约束,我同样要求你不要写死,根据不同关键信息定义中的内容,利用大模型(为确保快速处理,可不使用reasoning推理)给出相应的约束。
+
+--------
+
+针对某个提取值在原文中拥有多个对应文本的情况(如“王五”这个人在文中出现了多次),我新增了一条提取规则:“10. **同一提取值可多次标注**:如果某个提取值在原文中拥有多个对应文本,对这些文本分别进行标注,各标注的“节点路径”、“提取值”、“值约束”需保持一致,“原始文本”、“转换规则”可以不同,视原文决定”。但是经过测试,该规则未生效,多个对应文本只有一个被标注。请帮我检查并使之生效。
+
+--------
+
+请为我增加调试日志,在每一次与LLM交互前,打印本次交互是整个处理流程中的哪个分步骤,同时打印给LLM发送的prompt,以便我了解发送的内容。同时,在最外层函数调用处增加debug变量,只有当它为True时才打印上述日志。
+
+--------
+
+目前,在转换规则生成过程中,由于直接把整篇文章给了LLM,导致LLM识别提取值对应的原文不清晰(例如提取值“3000.0”对应文中的“3000”,但LLM认为是“3000元”)。所以,我想将“Step 2: LLM辅助生成约束和转换规则”拆分成两步,第一步将提取值对应的原文提取出来,第二步仅根据对应的原文进行约束和转换规则的生成。为了速度快,两步均采用大模型的轻量级调用,不调用reasoning推理。
+
+--------
+
+现在,我希望做一个界面,左侧为菜单栏,目前包含两个菜单:文档解析和信息管理。
+1. 文档解析:分为两栏,左栏为文档展示区域,如未选择文档,显示“请选择文档”;上传文档后,左侧可浏览文档(支持pdf和word),右侧区域分为上下两栏,比例约为3:1,上方显示从文档中提取的正文内容(不含页眉页脚等,可转为markdown格式),右上方有一个按钮,“开始解析”,点击后,下栏开始输出正文内容关键信息提取的思考过程(即原python文件输出的内容);思考完成后,上方内容区域变为提取后的文本(带{{||}})。
+2. 信息管理:针对关键信息树状结构的管理维护模块,以折叠树形式展示,每个节点均可编辑其元数据和子节点(参考之前的设计:**关键信息定义**:使用Json定义的树状结构,每个节点以`node_label`为key,包含`metadata`字段和`subnodes`字段,`metadata`字段中包含`node_label`(取关键信息名称的拼音首字母、`node_name`(关键信息名称)、`node_type`(取值为`concept`时表示分类节点,`property`时表示属性节点)、`value_type`(仅当`node_type`为`property`时生效,取值为`integer`、`float`、`string`、`boolean`、`date_time`、`class`,其中`class`表示关键信息为复合结构,包含子属性)、`is_list`(仅当`node_type`为`property`时生效,标识关键信息是否为列表类型,即是否可以有多个实例)字段。)
+前端请使用vue实现,后端引入flask框架。请实现。
+
+---
+
+后端配置里有API Key,前端不用输入,请求发给后端,后端使用配置的API key即可。
+
+---
+
+将后端端口改为8754,前端端口改为8774,然后重启服务。
+
+---
+
+我要求左侧是文档预览,word打开什么样子就是什么样子,pdf打开什么样子就是什么样子;右侧md文字行距过大,减小一些
+
+---
+
+1. 上传pdf,报错:上传失败:文档解析失败: PyMuPDF 未安装,无法解析 PDF
+2. 上传word,不报错,右侧也能解析出来,但左侧为空白页面,无word文档显示。
+
+---
+
+点击上传,报错:上传失败:Promise.withResolvers is not a function
+
+---
+
+页面缩放大小为125%时,显示没有问题;100%时,右侧有一大块区域是空白的,鼠标也无法交互。请修复。
+
+---
+
+信息管理中,我希望不止可以管理这一条关键信息,而是整理一个列表,每一条关键信息有名称、描述等元数据,点开后是这个树状结构,可以进行管理。支持json格式的导入,导入时填入名称和描述,导入格式参考@data/samples/info_definition.json。
+
+---
+
+我希望在@extractor.py中写一个函数,输入一段文本,从中提取**关键信息定义**Json,格式参考@data/samples/info_definition.json,具体含义:使用Json定义的树状结构,每个节点以`node_label`为key,包含`metadata`字段和`subnodes`字段,`metadata`字段中包含`node_label`(取关键信息名称的拼音首字母、`node_name`(关键信息名称)、`node_type`(取值为`concept`时表示分类节点,`property`时表示属性节点)、`value_type`(仅当`node_type`为`property`时生效,取值为`integer`、`float`、`string`、`boolean`、`date_time`、`class`,其中`class`表示关键信息为复合结构,包含子属性)、`is_list`(仅当`node_type`为`property`时生效,标识关键信息是否为列表类型,即是否可以有多个实例)字段。使用大模型来实现,生成后校验Json结构是否正确,如不正确,给出错误信息让大模型重试。
+
+---
+
+创建一个 test_definition.py 示例脚本,但复用test.py 中已有的 API 配置调用模型
+
+
+---
+
+右下方的文本框里完全没有思考过程输出,之前annotator.py、extractor.py等文件的思考过程输出呢?请将它们返回给前端,并实时流式输出至右下方文本框中。
+
+---
+
+写个启动脚本吧,里面设置INFO_EXTRACTOR_API_KEY变量,我稍后填入key。
+
+---
+
+1. 界面右下方,我希望将当前Step也输出出来,当前阶段的思考过程和输出前方统一加4个空格缩进(原有n个空格缩进的话,变为n+4个),阶段之间用“====================================”隔开,例如:
+```
+阶段一:XXXXX
+    XXXX(思考过程)
+    XXXX(思考过程)
+    输出:
+    {
+      XXXXX
+    }
+====================================
+阶段2a:XXXX
+    ……
+====================================
+2. 增加功能:右侧上方区域,不再以markdown形式展示,直接将文字排版显示出来,类似于新闻页面,默认使用仿宋_GB2312字体。解析完成后,对于{{COL1||COL2||COL3||COL4||COL5}}中间的内容进行渲染,文字为原文(COL3),但使用灰色背景将对应文字框选。点击框选区域任意位置,弹出对话框,对话框内表单包含字段:
+①关键信息名称,值为COL1对应中文字段名,不可编辑;②关键信息值,值为COL2,可编辑;这里你需要根据COL1对应的数据类型和COL4生成组件,如COL1为数字,这里就是数字输入框;COL1为时间,这里就是时间选择框;COL1为字符串,这里就是文本框;COL1为字符串,且COL4约束为枚举型,这里就是下拉选择框;等等。③文中展示值,值为COL3,可编辑,文本框。修改并确认后,后台更新{{}}中的COL2和COL3值,原文中灰色背景框选的部分更新为新的COL3值。
+
+---
+
+提取报错:提取失败:maximum recursion depth exceeded in comparison
+
+---
+
+基本实现了,但存在两个问题:
+1. 灰色选中的区域,不要换行显示,嵌入原文中即可。我以[]表示灰色区域,示例:
+```
+[小明]是个[医生]。
+```
+而非
+```
+[小明]
+是个
+[医生]
+。
+```
+2. 文本区域,段落与段落之间空行太多,去掉,一个空行都不要。
+
+---
+
+另外,右下方思考过程输出时,区域内滚动条自动不断滚到最底部吧
+
+---
+
+你个垃圾,灰色框选区域不要换行展示,而是嵌入文中,你一点没改;段落与段落之间一个空行都不要,但不是不要换行,还是要一个换行的。你行不行?你不行有的是大模型能干。
+
+---
+
+有没有一种可能,你用<span>,它灰色区域就一定会换行?我需要它在原文中的原位置,不能换行显示。
+
+---
+
+基本实现了,但段落之间的换行还要留着啊,怎么没了?从前后端都找找问题。
+另外,每个段落前要缩进2字符。
+
+---
+
+当关键信息在段首时,你的处理方式会导致它前面的换行丢失,与上一段接在一起。请解决。
+另外,为了测试方便,在文本区域增加按钮:导入文本,让我粘贴带{{}}的文本进去,你直接展示,省去标注流程。
+
+---
+
+段落间的换行又没了,你仔细看看怎么回事,我的要求:段落间的换行要留着,尤其注意段首有关键信息的情况;关键信息两侧的换行不要(除非它在段落首尾)
+
+---
+
+.replace(/(\{\{[^{}]*\}\})/g, (m) => m.replace(/\n+/g, '')),这个语句是干什么用的?逻辑不对吧,执行完以后,文中就没有段落间的换行了
+
+---
+
+function confirmImport() {
+  const text = importText.value || ''
+  if (!text.trim()) {
+    ElMessage.warning('请粘贴要导入的文本')
+    return
+  }
+  annotatedContent.value = normalizeNewsText(text)
+  hasResult.value = true
+  importDialogVisible.value = false
+  ElMessage.success('已导入文本')
+  persistAnnotation()
+}
+这个方法中,normalizeNewsText(text)的结果含\n,为什么最终渲染出来的丢失了换行符?
+
+---
+
+导入时输入:
+1
+2
+3
+解析完后,也显示 1 2 3,换行到底怎么回事???
+
+---
+
+我希望启动前端,但页面上不显示vue调试工具
+
+---
+
+取消所有TODO。现在,信息管理中,条目无法删除,请实现。写完后,不要启动playwright验证。
+
+---
+
+把模型的model_name、base_url都做到start_backend的两个脚本里。
+
+---
+
+现在,我的test.py可以正常使用大模型推理,但使用start_backend.bat调用app.py中的推理,报错: {'error': {'code': '1113', 'message': '余额不足或无可用资源包,请充值。'}},请帮我检查配置。
+
+---
+
+test.py 日志:POST https://open.bigmodel.cn/api/coding/paas/v4/chat/completions "HTTP/1.1 200 OK"
+app.py 日志:POST https://open.bigmodel.cn/api/coding/paas/v4/chat/completions "HTTP/1.1 429 Too Many Requests"
+你发个请求验证吧
+
+---
+
+三个问题:
+1. 最终输出结果,前后容易带上<article>和</article>,过滤掉;
+2. 对照我的原始需求:“右侧上方区域,不再以markdown形式展示,直接将文字排版显示出来,类似于新闻页面,默认使用仿宋_GB2312字体。解析完成后,对于{{COL1||COL2||COL3||COL4||COL5}}中间的内容进行渲染,文字为原文(COL3),但使用灰色背景将对应文字框选。点击框选区域任意位置,弹出对话框,对话框内表单包含字段:①关键信息名称,值为COL1对应中文字段名,不可编辑;②关键信息值,值为COL2,可编辑;这里你需要根据COL1对应的数据类型和COL4生成组件,如COL1为数字,这里就是数字输入框;COL1为时间,这里就是时间选择框;COL1为字符串,这里就是文本框;COL1为字符串,且COL4约束为枚举型,这里就是下拉选择框;等等。③文中展示值,值为COL3,可编辑,文本框。修改并确认后,后台更新{{}}中的COL2和COL3值,原文中灰色背景框选的部分更新为新的COL3值。” 你现在在灰色背景框选区域中,显示的是COL2的值,而非COL3的值;另外,弹出对话框中,“关键信息值”应为COL2的值,目前为空值;“文中展示值”应为COL3的值,目前为COL2的值。请修改;
+3. 思考过程容易输出英文思考,请强调输出中文思考过程。
+
+---
+
+你说“LLM 有时输出 {{path||raw_text||...}}(第 2 段空、第 3 段填了原文),看起来像"COL2 显示成了 COL3"。” 根据我观察,不是的,就算{{}}中内容正确,前端处理也有问题。
+另外,我不希望完全依赖大模型,请写一个方法,校验LLM输出的结果,{{}}外的内容+{{}}内的原文字段,能否完全匹配原文,若不能,进行相应修改后再输出。
+
+---
+
+这个新的修复步骤,是否在日志和前端思考过程输出中打印了?如果没有,请补充
+
+---
+
+重建报错: [修复] text-anchored 重建不可行:text segment 在原文中找不到(从位置 230 起):'指挥员仍然感觉意犹未尽:"每次训练,都是一次全新挑战!"\n\n'
+就是因为LLM容易出现幻觉,比如,就算让LLM原样输出,它也容易把“”符号输出为""符号,或者出现一些小的问题。所以重建并不是只重建{{}}内的部分,还需要针对{{}}外的部分进行校验和修复,且保持最小的编辑距离。
+
+---
+
+“导入文本”和“提取完成”后,在渲染文本中的组件时,请把每个组件对应的关键信息值、文中展示值打印出来,我查看一下,现在还有问题。
+
+---
+
+@DocumentParse.vue文件中,第341行左右,
+    if (ch === '|') {
+      fields.push(buf)
+      buf = ''
+      continue
+    }
+这里有问题,分隔符为“||”而非“|”,修复它。这么明显的问题,为什么会出现???
+
+---
+

+ 5 - 0
frontend/prompts.txt → frontend/prompts-frontend.txt

@@ -43,3 +43,8 @@
 
 ---
 
+我的项目里还会有个 springboot 后端。请写一个 .gitignore。
+
+---
+
+我还会有python相关的模块,补充到 .gitingore。

+ 272 - 0
skills/tag-system/SKILL.md

@@ -0,0 +1,272 @@
+---
+name: tag-system
+description: 标签系统的设计、存储、导入导出和打标模式。适用于需要实现标签/分类功能的 Spring Boot + Vue 项目。
+version: 1.0.0
+source: local-code-analysis
+---
+
+# 标签系统 (Tag System)
+
+本项目的标签系统提供标签组管理、树形标签、导入导出、通用打标四大能力。
+
+## 1. 数据模型
+
+### 1.1 三表结构
+
+```
+tag_group          标签组(分类维度)
+  ├── id, name, description, sortOrder, createdAt, updatedAt
+
+tag                标签节点(自引用树,每个组有一个同名根节点 parentId=null)
+  ├── id, groupId, parentId, name, sortOrder, createdAt, updatedAt
+
+tag_assignment     打标关联(通用多对多)
+  ├── id, entityType, entityId, tagId, createdAt
+  └── 唯一约束: (entity_type, entity_id, tag_id)
+```
+
+### 1.2 关键约定
+
+- **同名根节点**:创建标签组时自动生成一个 `parentId=null` 的根标签,名称与组名相同。用户操作的标签都是根节点的子孙。
+- **扁平存储,前端建树**:`listTags` 返回扁平列表,前端 `buildTree()` 根据 `parentId` 构建树结构。
+- **导出跳过根节点**:导出时 `buildTagTree` 只输出根节点的子节点,不包含根节点本身。
+- **无 JPA 关系注解**:三张表通过逻辑外键(groupId、parentId、tagId)关联,不使用 `@OneToMany`/`@ManyToOne`。
+
+## 2. 后端代码结构
+
+```
+model/entity/
+  TagGroup.java          标签组实体 @Table(name = "tag_group")
+  Tag.java               标签节点实体 @Table(name = "tag")
+  TagAssignment.java     打标关联实体 @Table(name = "tag_assignment")
+
+model/dto/
+  TagExportDTO.java       导出格式(嵌套树结构)
+    └── TagGroupExport    { name, description, sortOrder, tags: List<TagNodeExport> }
+    └── TagNodeExport     { name, sortOrder, children: List<TagNodeExport> }  递归嵌套
+  TagImportDTO.java       导入请求 { data: TagExportDTO, strategies: Map<name, strategy> }
+  TagImportResultDTO.java 导入结果 { created, overwritten, merged, skipped, details }
+
+model/vo/
+  TagBriefVO.java         标签简要信息 { tagId, tagName, groupId, groupName }
+
+repository/
+  TagGroupRepository      findAllByOrderBySortOrderAsc
+  TagRepository           findByGroupIdOrderBySortOrderAsc, findByParentIdOrderBySortOrderAsc, deleteAllByGroupId
+  TagAssignmentRepository findByEntityTypeAndEntityId, deleteAllByEntityTypeAndEntityId
+
+service/TagService        接口
+service/impl/TagServiceImpl  实现(包含导入导出、合并算法、打标)
+
+controller/TagController  REST API(/api/tags/*)
+  └── 请求体 DTO 均定义为 static inner class(CreateGroupReq, SetTagsReq 等)
+```
+
+## 3. API 端点一览
+
+### 3.1 标签组 CRUD
+
+```
+GET    /api/tags/groups              列出所有标签组
+POST   /api/tags/groups              创建标签组
+POST   /api/tags/groups/{id}/edit    更新标签组
+POST   /api/tags/groups/{id}/delete  删除标签组
+```
+
+### 3.2 标签节点 CRUD
+
+```
+GET    /api/tags/groups/{groupId}/tags     获取该组全部标签(扁平列表)
+POST   /api/tags/groups/{groupId}/tags     创建标签节点
+POST   /api/tags/{id}/edit                 重命名标签
+POST   /api/tags/{id}/delete               删除标签(递归删除子孙)
+POST   /api/tags/{id}/move                 移动标签(含防循环检测)
+```
+
+### 3.3 导入导出
+
+```
+GET    /api/tags/export     导出所有标签组(嵌套树 JSON)
+POST   /api/tags/import     导入标签(支持 overwrite/merge/skip 策略)
+```
+
+### 3.4 打标
+
+```
+POST   /api/tags/assignments              设置实体标签(全量替换)
+GET    /api/tags/assignments?entityType=&entityId=  获取实体标签
+GET    /api/tags/all-with-tags            所有标签组+标签(标签选择器用)
+```
+
+### 3.5 设计约定
+
+- **写操作用 POST**(非 RESTful DELETE/PUT),保持项目统一风格。
+- **请求体定义为 Controller 内部 static class**,使用 `@Data`(Lombok)。
+
+## 4. 导入导出规范
+
+### 4.1 导出格式
+
+```json
+{
+  "version": "1.0",
+  "exportedAt": "2026-06-11T14:30:00",
+  "groups": [
+    {
+      "name": "标签组名",
+      "description": "可选",
+      "sortOrder": 0,
+      "tags": [
+        {
+          "name": "标签名",
+          "sortOrder": 0,
+          "children": [ { "name": "子标签", "children": [] } ]
+        }
+      ]
+    }
+  ]
+}
+```
+
+- 导出数据不包含数据库 ID,以 `name` 作为匹配键。
+- `tags` 是根节点的子节点,不包含标签组同名根节点。
+
+### 4.2 导入策略
+
+| 策略 | 值 | 行为 |
+|------|---|------|
+| 新建 | `create` | 无冲突时自动新建 |
+| 覆盖 | `overwrite` | 删除旧组+旧标签,用导入数据重建 |
+| 合并 | `merge` | 递归按名称匹配:同名保留+递归子节点,新名追加 |
+| 跳过 | `skip` | 不做任何修改 |
+
+### 4.3 合并算法
+
+```
+对于导入标签树的每一层节点:
+  1. 在目标标签组的同级子节点中查找同名标签
+  2. 找到 → 保留该标签,递归处理其子节点
+  3. 未找到 → 创建新标签节点
+```
+
+幂等安全:同名标签不会重复创建。
+
+## 5. 打标规范
+
+### 5.1 打标原则
+
+1. **多标签**:一个实体可以打 0~N 个标签
+2. **同组多标**:同一标签组内可选多个标签
+3. **跨组打标**:可选择不同标签组的标签
+4. **非叶子可标**:树形标签中任意层级节点都可选中
+5. **全量替换**:每次保存提交完整 tagIds 列表,后端先删旧关联再建新关联
+
+### 5.2 实现
+
+```java
+// TagServiceImpl.setTags()
+assignmentRepo.deleteAllByEntityTypeAndEntityId(entityType, entityId);
+for (Long tagId : tagIds) {
+    // 逐条插入
+}
+```
+
+### 5.3 实体类型
+
+| entityType | entityId 来源 | 说明 |
+|------------|--------------|------|
+| `skill` | folderName | 技能(文件系统存储,无数据库 ID) |
+
+新增实体类型只需在调用方传入不同的 `entityType` 和 `entityId`,无需后端改动。
+
+## 6. 前端架构
+
+```
+api/tag.js                    所有标签 API 封装
+stores/tag.js                 Pinia store(groups, activeGroupId, currentTags, buildTree)
+components/tag/
+  TreeNodeItem.vue            递归树节点(编辑模式)
+  TagSelector.vue             标签选择器弹窗(打标模式)
+views/TagManagement.vue       标签管理页面(左右分栏)
+```
+
+### 6.1 TagSelector 组件使用方式
+
+```vue
+<TagSelector
+  v-model:show="showTagSelector"
+  entity-type="skill"
+  :entity-id="skill.folderName"
+  :selected-tag-ids="skill.tags?.map(t => t.tagId) || []"
+  @saved="onTagsSaved"
+/>
+```
+
+### 6.2 标签选择器交互
+
+1. 弹窗打开时调用 `getAllTagsForSelector()` 加载所有标签
+2. 树形浏览:按标签组展开/折叠,checkbox 勾选任意层级节点
+3. 搜索模式:关键词过滤标签名和组名
+4. 已选标签在顶部展示为可移除 Tag
+5. 确认后调用 `setEntityTags()` 全量替换,触发 `@saved` 回调
+
+### 6.3 buildTree 算法
+
+```javascript
+// 将扁平 parentId 列表构建为嵌套树
+function buildTree() {
+  const map = {}
+  const roots = []
+  currentTags.value.forEach(t => { map[t.id] = { ...t, children: [] } })
+  currentTags.value.forEach(t => {
+    if (t.parentId == null) roots.push(map[t.id])
+    else if (map[t.parentId]) map[t.parentId].children.push(map[t.id])
+  })
+  return roots
+}
+```
+
+## 7. 关键实现细节
+
+### 7.1 根节点处理
+
+标签组的根节点是隐式的(`parentId=null`,名称等于组名):
+- **CRUD**:前端在根节点的 children 上操作
+- **导出**:`buildTagTree` 跳过根节点,直接输出其子节点
+- **导入**:`importCreate` 找到自动创建的根节点,将导入的标签作为其子节点插入
+
+### 7.2 防循环移动
+
+```java
+// TagServiceImpl.isDescendant()
+// 检查 targetId 是否是 ancestorId 的子孙,防止 moveTag 形成环
+while (current != null) {
+    if (current.equals(ancestorId)) return true;
+    // 向上遍历 parentId 链
+}
+```
+
+### 7.3 Skill 列表携带标签
+
+```java
+// SkillController.toVOWithCache()
+vo.setTags(tagService.getEntityTags("skill", dto.getFolderName()));
+```
+
+Skill 无数据库实体,使用 `folderName` 作为 `entityId`。
+
+## 8. 扩展指南
+
+### 新增业务对象打标
+
+1. 前端在对应页面引入 `TagSelector` 组件
+2. 传入 `entityType="your-type"` 和 `entityId="your-id"`
+3. 无需后端改动,通用 API 自动支持
+
+### 新增标签组级字段
+
+1. 在 `TagGroup` 实体添加字段
+2. 更新 `CreateGroupReq` / `UpdateGroupReq`
+3. 更新 `TagGroupExport` 和 `importCreate` / `importOverwrite`
+4. 更新前端 `TagManagement.vue` 表单
+5. 更新 `docs/tag-system-spec.md` 格式规范