# Tag System Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 实现一个层级标签系统，标签作为 KGNode（entity_type="tag"），支持 AI 自动生成标签、标签关系网络、内容关联发现。

**Architecture:**
- 标签存储为 KGNode（entity_type="tag"），使用路径格式（tag_path）表示层级
- 标签关系通过 KGEdge 表示（parent_of, related_to, synonym_of）
- 内容节点通过 `properties_.tag_node_ids` 关联到标签节点
- AI 服务自动从内容中提取标签并建立标签关系

**Tech Stack:** FastAPI, SQLAlchemy, LLM API

---

## File Structure

```
backend/app/
├── models/
│   └── knowledge_graph.py          # 修改: KGNode 支持 tag 节点
├── schemas/
│   └── graph.py                    # 修改: 新增 TagSchema
├── services/
│   ├── tag_service.py              # 新建: 标签提取与管理服务
│   └── __init__.py
└── routers/
    └── graph.py                    # 修改: 新增标签相关 API
```

---

## Task 1: Extend KGNode Properties for Tags

**Files:**
- Modify: `backend/app/models/knowledge_graph.py:1-33`
- Modify: `backend/app/schemas/graph.py:1-35`

- [ ] **Step 1: Add TagProperties schema**

```python
# backend/app/schemas/graph.py
from pydantic import BaseModel, Field
from typing import Literal

class TagProperties(BaseModel):
    tag_path: str = Field(..., description="完整标签路径，如 '编程语言/Python/异步'")
    short_name: str = Field(..., description="显示名称，如 '异步'")
    level: int = Field(..., ge=1, description="层级深度，1为顶级")
    parent_path: str | None = Field(None, description="父路径，如 '编程语言/Python'")
    description: str | None = Field(None, description="AI生成的标签描述")
    color: str | None = Field(None, description="标签颜色，如 '#FF5733'")
```

- [ ] **Step 2: Update KGNodeOut schema**

```python
class KGNodeOut(BaseModel):
    id: str
    name: str
    entity_type: str
    description: str | None
    properties_: dict | None
    importance: float
    created_at: str
    # 新增：如果是 tag 节点，返回 tag 属性
    tag_properties: TagProperties | None = None

    model_config = {"from_attributes": True}

    def model_post_init(self, __context):
        if self.entity_type == "tag" and self.properties_:
            self.tag_properties = TagProperties(**self.properties_)
```

- [ ] **Step 3: Update KGNode model comment**

在 `KGNode` 类的 `entity_type` 注释中新增 `"tag"` 选项：
```python
entity_type = Column(String(100), nullable=False)  # person, concept, task, document, chunk, tag
```

- [ ] **Step 4: Commit**

```bash
git add backend/app/models/knowledge_graph.py backend/app/schemas/graph.py
git commit -m "feat: extend KGNode for tag support with path-based hierarchy"
```

---

## Task 2: Create Tag Service

**Files:**
- Create: `backend/app/services/tag_service.py`

- [ ] **Step 1: Write tag extraction prompt template**

```python
# backend/app/services/tag_service.py
TAG_EXTRACTION_PROMPT = """你是一个知识分类专家。从给定内容中提取标签。

要求：
1. 标签采用层级路径格式，如 "编程语言/Python"、"后端/框架/FastAPI"
2. 层级深度 1-4 层，避免过深
3. 每个内容提取 3-8 个标签
4. 标签应覆盖：主题、技术栈、领域、任务类型等维度

输出格式（JSON数组）：
[
  {{"path": "编程语言/Python", "description": "Python编程语言相关"}},
  {{"path": "后端/框架/FastAPI", "description": "FastAPI框架相关"}}
]

内容：
{content}
"""

TAG_RELATION_PROMPT = """分析以下标签之间的关系，输出 JSON 数组：

关系类型：
- parent_of: 父子关系（上级包含下级）
- related_to: 语义相关（但不是父子）
- synonym_of: 同义词

标签列表：
{tag_paths}

输出格式：
[
  {{"source": "标签1", "target": "标签2", "relation": "related_to", "weight": 0.8}},
  {{"source": "标签1", "target": "标签3", "relation": "parent_of", "weight": 1.0}}
]
"""
```

- [ ] **Step 2: Write TagService class**

```python
# backend/app/services/tag_service.py
import json
import re
from typing import Annotated
from sqlalchemy.orm import Session
from app.models.knowledge_graph import KGNode, KGEdge

class TagService:
    def __init__(self, db: Session, llm_client):
        self.db = db
        self.llm_client = llm_client

    def extract_tags_from_content(
        self, content: str, user_id: str
    ) -> list[dict]:
        """从内容中提取标签"""
        response = self.llm_client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": "你是一个知识分类专家。"},
                {"role": "user", "content": TAG_EXTRACTION_PROMPT.format(content=content)}
            ],
            response_format={"type": "json_object"}
        )
        result = json.loads(response.choices[0].message.content)
        return result.get("tags", [])

    def parse_tag_path(self, path: str) -> tuple[str, int, str | None]:
        """解析标签路径，返回 (short_name, level, parent_path)"""
        parts = path.strip("/").split("/")
        short_name = parts[-1]
        level = len(parts)
        parent_path = "/".join(parts[:-1]) if level > 1 else None
        return short_name, level, parent_path

    def get_or_create_tag_node(
        self, tag_info: dict, user_id: str
    ) -> KGNode:
        """获取或创建标签节点"""
        path = tag_info["path"]
        existing = self.db.query(KGNode).filter(
            KGNode.user_id == user_id,
            KGNode.properties_["tag_path"].astext == path
        ).first()

        if existing:
            return existing

        short_name, level, parent_path = self.parse_tag_path(path)

        node = KGNode(
            user_id=user_id,
            name=short_name,
            entity_type="tag",
            description=tag_info.get("description"),
            properties_={
                "tag_path": path,
                "short_name": short_name,
                "level": level,
                "parent_path": parent_path,
                "description": tag_info.get("description"),
                "color": tag_info.get("color"),
            },
            importance=0.5
        )
        self.db.add(node)
        self.db.flush()
        return node

    def ensure_parent_tags(self, path: str, user_id: str) -> list[KGNode]:
        """确保父路径标签存在"""
        parts = path.strip("/").split("/")
        nodes = []
        for i in range(1, len(parts)):
            parent_path = "/".join(parts[:i])
            tag_info = {"path": parent_path, "description": None}
            node = self.get_or_create_tag_node(tag_info, user_id)
            nodes.append(node)
        return nodes

    def create_tag_relations(
        self, tag_paths: list[str], user_id: str
    ) -> list[KGEdge]:
        """分析并创建标签之间的关系边"""
        # 构建标签节点映射
        path_to_node = {}
        for path in tag_paths:
            node = self.db.query(KGNode).filter(
                KGNode.user_id == user_id,
                KGNode.properties_["tag_path"].astext == path,
                KGNode.entity_type == "tag"
            ).first()
            if node:
                path_to_node[path] = node

        # 调用 LLM 分析关系
        response = self.llm_client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": "你是一个知识图谱专家。"},
                {"role": "user", "content": TAG_RELATION_PROMPT.format(tag_paths=json.dumps(tag_paths))}
            ],
            response_format={"type": "json_object"}
        )
        result = json.loads(response.choices[0].message.content)
        relations = result.get("relations", [])

        edges = []
        for rel in relations:
            source_node = path_to_node.get(rel["source"])
            target_node = path_to_node.get(rel["target"])
            if source_node and target_node:
                # 检查边是否已存在
                existing = self.db.query(KGEdge).filter(
                    KGEdge.source_id == source_node.id,
                    KGEdge.target_id == target_node.id
                ).first()
                if not existing:
                    edge = KGEdge(
                        source_id=source_node.id,
                        target_id=target_node.id,
                        relation_type=rel["relation"],
                        weight=rel.get("weight", 0.5)
                    )
                    self.db.add(edge)
                    edges.append(edge)

        self.db.flush()
        return edges

    def tag_content(
        self, content: str, user_id: str, content_node: KGNode
    ) -> list[KGNode]:
        """为内容节点打标签"""
        # 1. 提取标签
        tag_infos = self.extract_tags_from_content(content, user_id)
        tag_paths = [t["path"] for t in tag_infos]

        # 2. 创建/获取标签节点（包含父路径）
        tag_nodes = []
        for tag_info in tag_infos:
            node = self.get_or_create_tag_node(tag_info, user_id)
            tag_nodes.append(node)
            # 确保父标签存在
            self.ensure_parent_tags(tag_info["path"], user_id)

        # 3. 更新内容节点的 tag_node_ids
        tag_node_ids = [n.id for n in tag_nodes]
        current_tag_ids = content_node.properties_.get("tag_node_ids", [])
        content_node.properties_["tag_node_ids"] = list(set(current_tag_ids + tag_node_ids))

        # 4. 分析并创建标签关系
        if len(tag_paths) >= 2:
            self.create_tag_relations(tag_paths, user_id)

        self.db.commit()
        return tag_nodes

    def get_related_content(
        self, tag_node_ids: list[str], user_id: str, limit: int = 10
    ) -> list[tuple[KGNode, float]]:
        """通过标签找相关内容"""
        # 找所有关联到这些标签的内容节点
        edges = self.db.query(KGEdge).filter(
            KGEdge.target_id.in_(tag_node_ids),
            KGEdge.relation_type == "has_tag"
        ).all()

        # 统计共现次数作为权重
        content_weights: dict[str, float] = {}
        for edge in edges:
            content_weights[edge.source_id] = content_weights.get(edge.source_id, 0) + edge.weight

        # 获取内容节点
        content_ids = list(content_weights.keys())
        content_nodes = self.db.query(KGNode).filter(
            KGNode.id.in_(content_ids),
            KGNode.entity_type.in_(["conversation", "document", "chunk"])
        ).all()

        return [(node, content_weights[node.id]) for node in content_nodes]
```

- [ ] **Step 3: Commit**

```bash
git add backend/app/services/tag_service.py
git commit -m "feat: add TagService for AI-powered tag extraction and management"
```

---

## Task 3: Add Tag API Routes

**Files:**
- Modify: `backend/app/routers/graph.py`

- [ ] **Step 1: Read existing router**

```bash
cat backend/app/routers/graph.py
```

- [ ] **Step 2: Add tag-related schemas**

```python
# 在 graph.py 末尾添加
class TagExtractRequest(BaseModel):
    content: str = Field(..., min_length=10)
    user_id: str

class TagExtractResponse(BaseModel):
    tags: list[TagProperties]
    tag_count: int

class TagRelationRequest(BaseModel):
    tag_paths: list[str] = Field(..., min_length=2)
    user_id: str

class RelatedContentRequest(BaseModel):
    tag_ids: list[str]
    user_id: str
    limit: int = 10
```

- [ ] **Step 3: Add tag endpoints**

```python
@router.post("/tags/extract", response_model=TagExtractResponse)
async def extract_tags(request: TagExtractRequest, db: Session = Depends(get_db)):
    """从内容中提取标签（不保存到节点）"""
    # 需要注入 LLM client
    from app.services.tag_service import TagService
    from app.core.llm import get_llm_client

    llm_client = get_llm_client()
    tag_service = TagService(db, llm_client)

    tag_infos = tag_service.extract_tags_from_content(request.content, request.user_id)
    tags = []
    for t in tag_infos:
        short_name, level, parent_path = tag_service.parse_tag_path(t["path"])
        tags.append(TagProperties(
            tag_path=t["path"],
            short_name=short_name,
            level=level,
            parent_path=parent_path,
            description=t.get("description")
        ))

    return TagExtractResponse(tags=tags, tag_count=len(tags))

@router.post("/tags/content/{node_id}", response_model=TagExtractResponse)
async def tag_content_node(
    node_id: str,
    request: TagExtractRequest,
    db: Session = Depends(get_db)
):
    """为内容节点打标签"""
    from app.services.tag_service import TagService
    from app.core.llm import get_llm_client

    node = db.query(KGNode).filter(KGNode.id == node_id).first()
    if not node:
        raise HTTPException(status_code=404, detail="Node not found")

    llm_client = get_llm_client()
    tag_service = TagService(db, llm_client)

    tag_nodes = tag_service.tag_content(request.content, request.user_id, node)
    tags = []
    for n in tag_nodes:
        props = n.properties_ or {}
        tags.append(TagProperties(
            tag_path=props.get("tag_path", n.name),
            short_name=n.name,
            level=props.get("level", 1),
            parent_path=props.get("parent_path"),
            description=n.description
        ))

    return TagExtractResponse(tags=tags, tag_count=len(tags))

@router.get("/tags/{user_id}", response_model=list[KGNodeOut])
async def get_user_tags(user_id: str, db: Session = Depends(get_db)):
    """获取用户的所有标签"""
    nodes = db.query(KGNode).filter(
        KGNode.user_id == user_id,
        KGNode.entity_type == "tag"
    ).order_by(KGNode.properties_["level"].astext).all()

    return nodes

@router.get("/tags/{tag_id}/related", response_model=list[KGNodeOut])
async def get_related_tags(tag_id: str, db: Session = Depends(get_db)):
    """获取标签的关联标签"""
    # 获取该标签的所有关联边
    edges = db.query(KGEdge).filter(
        or_(KGEdge.source_id == tag_id, KGEdge.target_id == tag_id),
        KGEdge.relation_type.in_(["related_to", "synonym_of"])
    ).all()

    related_ids = set()
    for e in edges:
        if e.source_id == tag_id:
            related_ids.add(e.target_id)
        else:
            related_ids.add(e.source_id)

    if not related_ids:
        return []

    nodes = db.query(KGNode).filter(KGNode.id.in_(related_ids)).all()
    return nodes

@router.post("/content/related", response_model=list[KGNodeOut])
async def get_related_content(
    request: RelatedContentRequest,
    db: Session = Depends(get_db)
):
    """通过标签找相关内容"""
    from app.services.tag_service import TagService
    from app.core.llm import get_llm_client

    llm_client = get_llm_client()
    tag_service = TagService(db, llm_client)

    results = tag_service.get_related_content(request.tag_ids, request.user_id, request.limit)
    nodes = [r[0] for r in results]
    return nodes
```

- [ ] **Step 4: Commit**

```bash
git add backend/app/routers/graph.py
git commit -m "feat: add tag API routes for extraction and retrieval"
```

---

## Task 4: Add has_tag Edge Creation

**Files:**
- Modify: `backend/app/services/tag_service.py`

- [ ] **Step 1: Add has_tag edge when tagging content**

在 `tag_content` 方法中，添加创建 `has_tag` 边的逻辑：

```python
# 在 `tag_content` 方法中，`更新内容节点的 tag_node_ids` 之前添加：

# 创建 has_tag 边
for tag_node in tag_nodes:
    existing_edge = self.db.query(KGEdge).filter(
        KGEdge.source_id == content_node.id,
        KGEdge.target_id == tag_node.id,
        KGEdge.relation_type == "has_tag"
    ).first()
    if not existing_edge:
        edge = KGEdge(
            source_id=content_node.id,
            target_id=tag_node.id,
            relation_type="has_tag",
            weight=1.0
        )
        self.db.add(edge)
```

- [ ] **Step 2: Commit**

```bash
git add backend/app/services/tag_service.py
git commit -m "fix: create has_tag edges when tagging content"
```

---

## Task 5: Write Unit Tests

**Files:**
- Create: `tests/backend/app/services/test_tag_service.py`

- [ ] **Step 1: Write tests**

```python
import pytest
from unittest.mock import MagicMock, patch
from app.services.tag_service import TagService

class TestTagService:
    def test_parse_tag_path_single_level(self):
        service = TagService(db=MagicMock(), llm_client=MagicMock())
        short_name, level, parent_path = service.parse_tag_path("Python")

        assert short_name == "Python"
        assert level == 1
        assert parent_path is None

    def test_parse_tag_path_nested(self):
        service = TagService(db=MagicMock(), llm_client=MagicMock())
        short_name, level, parent_path = service.parse_tag_path("编程语言/Python/异步")

        assert short_name == "异步"
        assert level == 3
        assert parent_path == "编程语言/Python"

    def test_parse_tag_path_strips_slashes(self):
        service = TagService(db=MagicMock(), llm_client=MagicMock())
        short_name, level, parent_path = service.parse_tag_path("/后端/框架/")

        assert short_name == "框架"
        assert level == 2
        assert parent_path == "后端"

    @patch('app.services.tag_service.KGNode')
    @patch('app.services.tag_service.KGEdge')
    def test_get_or_create_tag_node_creates_new(self, mock_edge, mock_node):
        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = None

        service = TagService(db=mock_db, llm_client=MagicMock())
        tag_info = {"path": "Python", "description": "Python语言"}

        result = service.get_or_create_tag_node(tag_info, "user_123")

        assert result is not None
        mock_db.add.assert_called_once()
        mock_db.flush.assert_called_once()

    @patch('app.services.tag_service.KGNode')
    def test_get_or_create_tag_node_returns_existing(self, mock_node):
        mock_db = MagicMock()
        mock_existing = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = mock_existing

        service = TagService(db=mock_db, llm_client=MagicMock())
        tag_info = {"path": "Python", "description": "Python语言"}

        result = service.get_or_create_tag_node(tag_info, "user_123")

        assert result == mock_existing
        mock_db.add.assert_not_called()
```

- [ ] **Step 2: Run tests**

```bash
cd backend && python -m pytest tests/backend/app/services/test_tag_service.py -v
```

- [ ] **Step 3: Commit**

```bash
git add tests/backend/app/services/test_tag_service.py
git commit -m "test: add unit tests for TagService"
```

---

---

## Task 6: Add Scheduled Tag Generation Job

**Files:**
- Modify: `backend/app/services/scheduler_service.py:1-211`
- Modify: `backend/app/routers/scheduler.py:1-41`

- [ ] **Step 1: Add async tag service method**

在 `TagService` 中添加增量打标签方法：

```python
# 在 tag_service.py 添加

async def tag_incremental_content(self, user_id: str, days: int = 1) -> dict:
    """
    增量打标签 - 只对最近新增/更新的内容节点打标签
    - days: 追溯最近多少天内的内容
    """
    from datetime import datetime, timedelta

    cutoff_date = datetime.utcnow() - timedelta(days=days)

    # 查找尚未打标签的内容节点（tag_node_ids 为空或未设置）
    content_nodes = self.db.query(KGNode).filter(
        KGNode.user_id == user_id,
        KGNode.entity_type.in_(["conversation", "document", "chunk"]),
        KGNode.updated_at >= cutoff_date
    ).all()

    # 过滤掉已有标签的节点
    untagged = [
        n for n in content_nodes
        if not n.properties_.get("tag_node_ids")
    ]

    tagged_count = 0
    for node in untagged:
        content = node.description or ""
        try:
            self.tag_content(content, user_id, node)
            tagged_count += 1
        except Exception as e:
            logger.warning(f"标签化节点 {node.id} 失败: {e}")

    return {"total": len(untagged), "tagged": tagged_count}
```

- [ ] **Step 2: Add scheduled job function**

在 `scheduler_service.py` 中添加：

```python
async def tag_generation_task():
    """
    每日凌晨 00:00 增量标签生成任务
    - 扫描最近 24 小时新增/更新的内容
    - AI 自动提取标签
    - 建立标签关系网络
    """
    logger.info("[Scheduler] 开始执行每日标签生成...")

    async with async_session() as db:
        try:
            from app.services.tag_service import TagService
            from app.core.llm import get_llm_client

            llm_client = get_llm_client()
            tag_service = TagService(db, llm_client)

            # 获取所有有内容的用户
            result = await db.execute(
                select(KGNode.user_id).distinct().where(
                    KGNode.entity_type.in_(["conversation", "document", "chunk"])
                )
            )
            user_ids = result.scalars().all()

            total_tagged = 0
            for user_id in user_ids:
                # 同步调用增量打标签
                sync_tag_service = TagService(db, llm_client)
                result = sync_tag_service.tag_incremental_content(user_id, days=1)
                total_tagged += result["tagged"]

            logger.info(f"[Scheduler] 每日标签生成完成，共标签化 {total_tagged} 个内容节点")
        except Exception as e:
            logger.error(f"[Scheduler] 每日标签生成失败: {e}")
```

- [ ] **Step 3: Register the scheduled job**

在 `start_scheduler()` 函数中添加：

```python
# 每天凌晨 00:00 生成标签
scheduler.add_job(
    tag_generation_task,
    CronTrigger(hour=0, minute=0, timezone="Asia/Shanghai"),
    id="tag_generation",
    name="每日标签生成",
    replace_existing=True,
)
```

- [ ] **Step 4: Update scheduler router**

在 `routers/scheduler.py` 的 `job_map` 中添加：

```python
job_map = {
    "daily_task_analysis": daily_task_analysis,
    "forum_scan": forum_scan_task,
    "graph_rebuild": graph_rebuild_task,
    "tag_generation": tag_generation_task,  # 新增
}
```

- [ ] **Step 5: Commit**

```bash
git add backend/app/services/scheduler_service.py backend/app/routers/scheduler.py
git commit -m "feat: add daily scheduled tag generation at midnight"
```

---

## Summary

| Task | Description | Files |
|------|-------------|-------|
| 1 | Extend KGNode for tag support | `models/knowledge_graph.py`, `schemas/graph.py` |
| 2 | Create TagService for AI tag extraction | `services/tag_service.py` |
| 3 | Add tag API routes | `routers/graph.py` |
| 4 | Fix has_tag edge creation | `services/tag_service.py` |
| 5 | Write unit tests | `tests/backend/app/services/test_tag_service.py` |
| 6 | Add scheduled tag generation (00:00) | `services/scheduler_service.py`, `routers/scheduler.py` |
