    def add_entry(self, project: str, content: str) -> str:
        """Add a new info entry to the current week file.

        Args:
            project: Project directory name.
            content: Raw user input (text with optional '(From xxx)' suffix).

        Returns:
            Slack confirmation message.
        """
        raw_text, source = self._extract_source(content)
        if not raw_text.strip():
            return "⚠ 内容为空，未保存。"

        # Load recent entries for context
        week_file = self._get_week_file(project)
        existing = ""
        if week_file.exists():
            existing = week_file.read_text(encoding="utf-8")

        comment, tags = self._generate_comment_and_tags(raw_text, project, existing)

        now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
        tag_str = " ".join(tags) if tags else ""
        header = f"### {now} | {tag_str}".rstrip()

        lines = [
            header,
            "",
            *[f"> {line}" for line in raw_text.strip().split("\n")],
            "",
        ]
        if source:
            lines.append(f"来源: {source}")
            lines.append("")
        lines.append(f"**点评**: {comment}")
        lines.append("")
        lines.append("---")
        lines.append("")

        entry_text = "\n".join(lines)

        # Ensure directory and file exist
        week_file.parent.mkdir(parents=True, exist_ok=True)
        with open(week_file, "a", encoding="utf-8") as f:
            f.write(entry_text)

        tag_display = f" {tag_str}" if tag_str else ""
        source_display = f"\n📎 来源: {source}" if source else ""
        logger.info(f"InfoManager: added entry to {project}/{week_file.name}{tag_display}")
        return f"✅ 已保存到 *{project}*{tag_display}{source_display}\n\n> {comment}"

"""Project info fragment tracker for Obsidian Bot mode info."""

import re
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple

from pydantic import BaseModel, Field

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM

logger = setup_logger(__name__)

# Match (From xxx) or (from xxx) — case-insensitive, captures the source name
_SOURCE_RE = re.compile(r'\((?:[Ff]rom|FROM)\s+(.+?)\)\s*$')

INFO_SYSTEM_PROMPT = """You are a concise project note-taker.
You help organize and comment on project information fragments.
Always respond in Chinese unless the input is entirely in English."""

class InfoEntry(BaseModel):
    """Single information fragment parsed from a weekly Markdown file."""

    timestamp: str
    tags: List[str] = Field(default_factory=list)
    source: str = ""
    raw_content: str
    comment: str = ""
    updates: List[str] = Field(default_factory=list)
    # Line position in the source file for update insertion
    source_line_start: int = 0
    source_line_end: int = 0

class InfoManager:
    """Manages per-project weekly info files in the Obsidian vault.

    Directory layout::

        vault/info/<project>/YYYY-Www.md
    """

    def __init__(self, vault_path: Path):
        self.vault_path = vault_path
        self.info_dir = vault_path / "info"
        self.llm = GeminiLLM(system_instruction=INFO_SYSTEM_PROMPT)

    # ── Public API ────────────────────────────────────────────────────

    def add_entry(self, project: str, content: str) -> str:
        """Add a new info entry to the current week file.

        Args:
            project: Project directory name.
            content: Raw user input (text, URL, or mixed).

        Returns:
            Slack confirmation message.
        """
        raw_text, source_url = self._resolve_content(content)
        if not raw_text.strip():
            return "⚠ 内容为空，未保存。"

        # Load recent entries for context
        week_file = self._get_week_file(project)
        existing = ""
        if week_file.exists():
            existing = week_file.read_text(encoding="utf-8")

        comment, tags = self._generate_comment_and_tags(raw_text, project, existing)

        now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
        tag_str = " ".join(tags) if tags else ""
        header = f"### {now} | {tag_str}".rstrip()

        lines = [
            header,
            "",
            *[f"> {line}" for line in raw_text.strip().split("\n")],
            "",
        ]
        if source_url:
            lines.append(f"来源: {source_url}")
            lines.append("")
        lines.append(f"**点评**: {comment}")
        lines.append("")
        lines.append("---")
        lines.append("")

        entry_text = "\n".join(lines)

        # Ensure directory and file exist
        week_file.parent.mkdir(parents=True, exist_ok=True)
        with open(week_file, "a", encoding="utf-8") as f:
            f.write(entry_text)

        tag_display = f" {tag_str}" if tag_str else ""
        logger.info(f"InfoManager: added entry to {project}/{week_file.name}{tag_display}")
        return f"✅ 已保存到 *{project}*{tag_display}\n\n> {comment}"

    def list_recent(self, project: str, count: int = 5) -> Tuple[str, List[InfoEntry]]:
        """List the most recent entries for a project.

        Args:
            project: Project directory name.
            count: Max entries to show.

        Returns:
            Tuple of (formatted message, entry list).
        """
        entries = self._load_recent_entries(project, count)
        if not entries:
            return f"📭 项目 *{project}* 暂无记录。", []

        lines = [f"📋 *{project}* 最近 {len(entries)} 条信息：\n"]
        for i, e in enumerate(entries, 1):
            tag_str = " ".join(e.tags) if e.tags else ""
            # Truncate raw content for display
            preview = e.raw_content[:60].replace("\n", " ")
            if len(e.raw_content) > 60:
                preview += "..."
            update_count = f" (+{len(e.updates)}更新)" if e.updates else ""
            lines.append(f"*{i}.* {e.timestamp} {tag_str}{update_count}\n   {preview}")
        lines.append("\n输入序号选择要更新的条目，或发送其他内容新增信息。")
        return "\n".join(lines), entries

    def update_entry(
        self, project: str, entries: List[InfoEntry], index: int, content: str
    ) -> str:
        """Append an update to an existing entry.

        Args:
            project: Project directory name.
            entries: Entry list from list_recent.
            index: 0-based index into entries.
            content: Update content from user.

        Returns:
            Slack confirmation message.
        """
        if index < 0 or index >= len(entries):
            return f"⚠ 无效序号，请输入 1-{len(entries)} 之间的数字。"

        entry = entries[index]
        now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        update_line = f"**更新 {now}**: {content.strip()}"

        # Find the source file and insert before the closing ---
        week_files = self._get_week_files(project)
        for wf in week_files:
            file_lines = wf.read_text(encoding="utf-8").split("\n")
            # Match entry by timestamp header
            header_pattern = f"### {entry.timestamp}"
            for i, line in enumerate(file_lines):
                if line.startswith(header_pattern):
                    # Find the next --- separator
                    for j in range(i + 1, len(file_lines)):
                        if file_lines[j].strip() == "---":
                            # Insert update before ---
                            file_lines.insert(j, "")
                            file_lines.insert(j, update_line)
                            wf.write_text("\n".join(file_lines), encoding="utf-8")
                            logger.info(f"InfoManager: updated entry {entry.timestamp} in {project}")
                            return f"✅ 已更新 *{entry.timestamp}* 的记录。\n\n> {update_line}"
                    break

        return "⚠ 未找到对应条目，可能文件已被修改。"

    def summarize(self, project: str, extra_paths: Optional[List[str]] = None) -> str:
        """Generate a project summary from all info entries.

        Args:
            project: Project directory name.
            extra_paths: Optional vault-relative paths to include.

        Returns:
            LLM-generated summary.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法汇总。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。

要求：
1. 项目进展时间线（按时间排列关键事件）
2. 当前待解决问题
3. 关键决策和结论
4. 如有外部笔记，融合其内容进行分析

用中文回答，简洁直接。

=== 项目碎片信息 ===
{all_content}
{extra_text}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: generated summary for {project}")
        return response

    def ask(self, project: str, question: str, extra_paths: Optional[List[str]] = None) -> str:
        """Answer a free-form question based on project info entries.

        Args:
            project: Project directory name.
            question: User's question.
            extra_paths: Optional vault-relative paths to include as context.

        Returns:
            LLM-generated answer.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法回答。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 用中文回答，简洁直接
- 如有时间线相关，按时间顺序整理

=== 项目碎片信息 ===
{all_content}
{extra_text}

=== 用户问题 ===
{question}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: answered question for {project}")
        return response

    def search_entries(self, project: str, keyword: str) -> str:
        """Search entries by keyword across all week files.

        Args:
            project: Project directory name.
            keyword: Search term.

        Returns:
            Formatted search results.
        """
        week_files = self._get_week_files(project)
        if not week_files:
            return f"📭 项目 *{project}* 暂无记录。"

        matches: List[str] = []
        kw_lower = keyword.lower()
        for wf in week_files:
            entries = self._parse_entries(wf)
            for e in entries:
                full_text = f"{e.raw_content} {e.comment} {' '.join(e.updates)}"
                if kw_lower in full_text.lower():
                    tag_str = " ".join(e.tags) if e.tags else ""
                    preview = e.raw_content[:80].replace("\n", " ")
                    matches.append(f"• {e.timestamp} {tag_str}\n  {preview}")

        if not matches:
            return f"🔍 在 *{project}* 中未找到包含 \"{keyword}\" 的记录。"

        header = f"🔍 在 *{project}* 中找到 {len(matches)} 条匹配 \"{keyword}\"：\n"
        return header + "\n".join(matches[:10])

    def list_projects(self) -> str:
        """List all project directories.

        Returns:
            Formatted project list.
        """
        if not self.info_dir.exists():
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        projects = sorted(
            d.name for d in self.info_dir.iterdir() if d.is_dir()
        )
        if not projects:
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        lines = [f"📁 共 {len(projects)} 个项目：\n"]
        for p in projects:
            week_files = list((self.info_dir / p).glob("*.md"))
            entry_count = sum(
                len(self._parse_entries(wf)) for wf in week_files
            )
            lines.append(f"• *{p}* — {entry_count} 条记录，{len(week_files)} 个周文件")
        return "\n".join(lines)

    # ── Internal ──────────────────────────────────────────────────────

    def _get_week_file(self, project: str) -> Path:
        """Get the path for the current week's file, creating dir if needed."""
        now = datetime.now(timezone.utc)
        iso_year, iso_week, _ = now.isocalendar()
        filename = f"{iso_year}-W{iso_week:02d}.md"
        project_dir = self.info_dir / project
        project_dir.mkdir(parents=True, exist_ok=True)
        return project_dir / filename

    def _get_week_files(self, project: str) -> List[Path]:
        """Get all week files for a project, sorted newest first."""
        project_dir = self.info_dir / project
        if not project_dir.exists():
            return []
        files = sorted(project_dir.glob("*.md"), reverse=True)
        return files

    def _parse_entries(self, file_path: Path) -> List[InfoEntry]:
        """Parse a weekly Markdown file into structured entries."""
        if not file_path.exists():
            return []

        text = file_path.read_text(encoding="utf-8")
        entries: List[InfoEntry] = []

        # Split by ### headers
        blocks = re.split(r'^(### .+)$', text, flags=re.MULTILINE)

        # blocks = [preamble, header1, body1, header2, body2, ...]
        i = 1
        while i < len(blocks) - 1:
            header_line = blocks[i].strip()
            body = blocks[i + 1]

            # Parse header: ### 2026-03-17 14:30 | #ci #性能
            header_match = re.match(
                r'### (\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s*(?:\|\s*(.*))?$',
                header_line,
            )
            if not header_match:
                i += 2
                continue

            timestamp = header_match.group(1)
            tag_str = header_match.group(2) or ""
            tags = [t.strip() for t in tag_str.split() if t.strip().startswith("#")]

            # Parse body
            raw_lines: List[str] = []
            comment = ""
            updates: List[str] = []

            for line in body.split("\n"):
                stripped = line.strip()
                if stripped.startswith("> "):
                    raw_lines.append(stripped[2:])
                elif stripped.startswith("**点评**:") or stripped.startswith("**点评**: "):
                    comment = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**更新"):
                    updates.append(stripped)

            entries.append(InfoEntry(
                timestamp=timestamp,
                tags=tags,
                raw_content="\n".join(raw_lines),
                comment=comment,
                updates=updates,
            ))
            i += 2

        return entries

    def _load_recent_entries(self, project: str, count: int) -> List[InfoEntry]:
        """Load the most recent N entries across week files."""
        week_files = self._get_week_files(project)
        all_entries: List[InfoEntry] = []
        for wf in week_files:
            entries = self._parse_entries(wf)
            all_entries.extend(reversed(entries))  # newest first within file
            if len(all_entries) >= count:
                break
        return all_entries[:count]

    def _load_all_content(self, project: str) -> str:
        """Load all week files as raw text, oldest first."""
        week_files = self._get_week_files(project)
        parts: List[str] = []
        for wf in reversed(week_files):  # oldest first
            parts.append(f"=== {wf.stem} ===\n")
            parts.append(wf.read_text(encoding="utf-8"))
        return "\n".join(parts)

    def _resolve_content(self, content: str) -> Tuple[str, Optional[str]]:
        """Detect URLs in content and fetch if needed.

        Returns:
            Tuple of (resolved text, source_url or None).
        """
        url_match = _URL_RE.search(content)
        if not url_match:
            return content, None

        url = url_match.group(0)
        # Strip trailing Slack formatting junk
        url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<")

        # If content is mostly just the URL, fetch and replace
        non_url_text = content.replace(url_match.group(0), "").strip()
        try:
            fetched = self._fetch_url(url)
            if non_url_text:
                return f"{non_url_text}\n\n---\n{fetched[:2000]}", url
            return fetched[:2000], url
        except Exception as e:
            logger.warning(f"URL fetch failed for {url}: {e}")
            # Fall back to using the raw content as-is
            return content, url

    def _fetch_url(self, url: str) -> str:
        """Fetch URL content, reusing NoteIngester strategies."""
        if self._note_ingester is None:
            from slack_bot.obsidian.note_ingester import NoteIngester
            # Minimal init just for URL fetching
            self._note_ingester = NoteIngester.__new__(NoteIngester)
            self._note_ingester._MIN_CONTENT_LEN = 50
            NoteIngester._JINA_FIRST_DOMAINS = getattr(
                NoteIngester, '_JINA_FIRST_DOMAINS', set()
            )
            self._note_ingester._JINA_FIRST_DOMAINS = NoteIngester._JINA_FIRST_DOMAINS
        return self._note_ingester._fetch_url(url)

    def _generate_comment_and_tags(
        self, content: str, project: str, context: str
    ) -> Tuple[str, List[str]]:
        """Use LLM to generate a one-line comment and 1-3 tags.

        Args:
            content: The raw information text.
            project: Current project name.
            context: Existing entries in the current week file (for tag consistency).

        Returns:
            Tuple of (comment string, list of tag strings like ["#ci", "#性能"]).
        """
        # Truncate context to avoid prompt bloat
        ctx_preview = context[-1500:] if len(context) > 1500 else context

        prompt = f"""根据以下信息，生成：
1. 一句话点评（不超过50字，直接说判断和建议，不要废话）
2. 1-3 个标签（用 # 前缀，简短，和已有标签保持一致性）

项目名: {project}
已有条目（参考标签风格）:
{ctx_preview}

新信息:
{content}

严格按以下 JSON 格式输出，不要输出其他内容:
{{"comment": "...", "tags": ["#tag1", "#tag2"]}}"""

        try:
            response, _ = self.llm.generate_response(prompt, [])
            # Parse JSON from response
            json_match = re.search(r'\{[^}]+\}', response, re.DOTALL)
            if json_match:
                import json
                data = json.loads(json_match.group(0))
                comment = data.get("comment", "")
                tags = data.get("tags", [])
                # Ensure tags have # prefix
                tags = [t if t.startswith("#") else f"#{t}" for t in tags]
                return comment, tags[:3]
        except Exception as e:
            logger.warning(f"LLM comment/tag generation failed: {e}")

        return "（自动点评生成失败）", []

-            content: Raw user input (text, URL, or mixed).

+            content: Raw user input (text with optional '(From xxx)' suffix).

-        raw_text, source_url = self._resolve_content(content)

+        raw_text, source = self._extract_source(content)

return "⚠ 内容为空，未保存。"

-            lines.append(f"来源: {source_url}")

+            lines.append(f"来源: {source}")

+        source_display = f"\n📎 来源: {source}" if source else ""

-        return f"✅ 已保存到 *{project}*{tag_display}\n\n> {comment}"

+        return f"✅ 已保存到 *{project}*{tag_display}{source_display}\n\n> {comment}"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "post:quality-gate" "scripts/hooks/quality-gate.js" "standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "post:edit:format" "scripts/hooks/post-edit-format.js" "standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "post:edit:typecheck" "scripts/hooks/post-edit-typecheck.js" "standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "post:edit:console-warn" "scripts/hooks/post-edit-console-warn.js" "standard,strict"


bash "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh" "post:observe" "skills/continuous-learning-v2/hooks/observe.sh" "standard,strict"


更新 `_parse_entries` 解析 `来源:` 字段：


Edit

            # Parse body
            raw_lines: List[str] = []
            comment = ""
            updates: List[str] = []

            for line in body.split("\n"):
                stripped = line.strip()
                if stripped.startswith("> "):
                    raw_lines.append(stripped[2:])
                elif stripped.startswith("**点评**:") or stripped.startswith("**点评**: "):
                    comment = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**更新"):
                    updates.append(stripped)

            entries.append(InfoEntry(
                timestamp=timestamp,
                tags=tags,
                raw_content="\n".join(raw_lines),
                comment=comment,
                updates=updates,
            ))

            # Parse body
            raw_lines: List[str] = []
            comment = ""
            source = ""
            updates: List[str] = []

            for line in body.split("\n"):
                stripped = line.strip()
                if stripped.startswith("> "):
                    raw_lines.append(stripped[2:])
                elif stripped.startswith("来源: ") or stripped.startswith("来源:"):
                    source = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**点评**:") or stripped.startswith("**点评**: "):
                    comment = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**更新"):
                    updates.append(stripped)

            entries.append(InfoEntry(
                timestamp=timestamp,
                tags=tags,
                source=source,
                raw_content="\n".join(raw_lines),
                comment=comment,
                updates=updates,
            ))


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "pre:edit-write:suggest-compact" "scripts/hooks/suggest-compact.js" "standard,strict"


bash "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh" "pre:observe" "skills/continuous-learning-v2/hooks/observe.sh" "standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "pre:insaits-security" "scripts/hooks/insaits-security-wrapper.js" "standard,strict"


The file /root/projects/butler/slack_bot/obsidian/info_manager.py has been updated successfully.

            # Parse body
            raw_lines: List[str] = []
            comment = ""
            source = ""
            updates: List[str] = []

            for line in body.split("\n"):
                stripped = line.strip()
                if stripped.startswith("> "):
                    raw_lines.append(stripped[2:])
                elif stripped.startswith("来源: ") or stripped.startswith("来源:"):
                    source = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**点评**:") or stripped.startswith("**点评**: "):
                    comment = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**更新"):
                    updates.append(stripped)

            entries.append(InfoEntry(
                timestamp=timestamp,
                tags=tags,
                source=source,
                raw_content="\n".join(raw_lines),
                comment=comment,
                updates=updates,
            ))

"""Project info fragment tracker for Obsidian Bot mode info."""

import re
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple

from pydantic import BaseModel, Field

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM

logger = setup_logger(__name__)

# Match (From xxx) or (from xxx) — case-insensitive, captures the source name
_SOURCE_RE = re.compile(r'\((?:[Ff]rom|FROM)\s+(.+?)\)\s*$')

INFO_SYSTEM_PROMPT = """You are a concise project note-taker.
You help organize and comment on project information fragments.
Always respond in Chinese unless the input is entirely in English."""

class InfoEntry(BaseModel):
    """Single information fragment parsed from a weekly Markdown file."""

    timestamp: str
    tags: List[str] = Field(default_factory=list)
    source: str = ""
    raw_content: str
    comment: str = ""
    updates: List[str] = Field(default_factory=list)
    # Line position in the source file for update insertion
    source_line_start: int = 0
    source_line_end: int = 0

class InfoManager:
    """Manages per-project weekly info files in the Obsidian vault.

    Directory layout::

        vault/info/<project>/YYYY-Www.md
    """

    def __init__(self, vault_path: Path):
        self.vault_path = vault_path
        self.info_dir = vault_path / "info"
        self.llm = GeminiLLM(system_instruction=INFO_SYSTEM_PROMPT)

    # ── Public API ────────────────────────────────────────────────────

    def add_entry(self, project: str, content: str) -> str:
        """Add a new info entry to the current week file.

        Args:
            project: Project directory name.
            content: Raw user input (text with optional '(From xxx)' suffix).

        Returns:
            Slack confirmation message.
        """
        raw_text, source = self._extract_source(content)
        if not raw_text.strip():
            return "⚠ 内容为空，未保存。"

        # Load recent entries for context
        week_file = self._get_week_file(project)
        existing = ""
        if week_file.exists():
            existing = week_file.read_text(encoding="utf-8")

        comment, tags = self._generate_comment_and_tags(raw_text, project, existing)

        now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
        tag_str = " ".join(tags) if tags else ""
        header = f"### {now} | {tag_str}".rstrip()

        lines = [
            header,
            "",
            *[f"> {line}" for line in raw_text.strip().split("\n")],
            "",
        ]
        if source:
            lines.append(f"来源: {source}")
            lines.append("")
        lines.append(f"**点评**: {comment}")
        lines.append("")
        lines.append("---")
        lines.append("")

        entry_text = "\n".join(lines)

        # Ensure directory and file exist
        week_file.parent.mkdir(parents=True, exist_ok=True)
        with open(week_file, "a", encoding="utf-8") as f:
            f.write(entry_text)

        tag_display = f" {tag_str}" if tag_str else ""
        source_display = f"\n📎 来源: {source}" if source else ""
        logger.info(f"InfoManager: added entry to {project}/{week_file.name}{tag_display}")
        return f"✅ 已保存到 *{project}*{tag_display}{source_display}\n\n> {comment}"

    def list_recent(self, project: str, count: int = 5) -> Tuple[str, List[InfoEntry]]:
        """List the most recent entries for a project.

        Args:
            project: Project directory name.
            count: Max entries to show.

        Returns:
            Tuple of (formatted message, entry list).
        """
        entries = self._load_recent_entries(project, count)
        if not entries:
            return f"📭 项目 *{project}* 暂无记录。", []

        lines = [f"📋 *{project}* 最近 {len(entries)} 条信息：\n"]
        for i, e in enumerate(entries, 1):
            tag_str = " ".join(e.tags) if e.tags else ""
            # Truncate raw content for display
            preview = e.raw_content[:60].replace("\n", " ")
            if len(e.raw_content) > 60:
                preview += "..."
            update_count = f" (+{len(e.updates)}更新)" if e.updates else ""
            lines.append(f"*{i}.* {e.timestamp} {tag_str}{update_count}\n   {preview}")
        lines.append("\n输入序号选择要更新的条目，或发送其他内容新增信息。")
        return "\n".join(lines), entries

    def update_entry(
        self, project: str, entries: List[InfoEntry], index: int, content: str
    ) -> str:
        """Append an update to an existing entry.

        Args:
            project: Project directory name.
            entries: Entry list from list_recent.
            index: 0-based index into entries.
            content: Update content from user.

        Returns:
            Slack confirmation message.
        """
        if index < 0 or index >= len(entries):
            return f"⚠ 无效序号，请输入 1-{len(entries)} 之间的数字。"

        entry = entries[index]
        now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        update_line = f"**更新 {now}**: {content.strip()}"

        # Find the source file and insert before the closing ---
        week_files = self._get_week_files(project)
        for wf in week_files:
            file_lines = wf.read_text(encoding="utf-8").split("\n")
            # Match entry by timestamp header
            header_pattern = f"### {entry.timestamp}"
            for i, line in enumerate(file_lines):
                if line.startswith(header_pattern):
                    # Find the next --- separator
                    for j in range(i + 1, len(file_lines)):
                        if file_lines[j].strip() == "---":
                            # Insert update before ---
                            file_lines.insert(j, "")
                            file_lines.insert(j, update_line)
                            wf.write_text("\n".join(file_lines), encoding="utf-8")
                            logger.info(f"InfoManager: updated entry {entry.timestamp} in {project}")
                            return f"✅ 已更新 *{entry.timestamp}* 的记录。\n\n> {update_line}"
                    break

        return "⚠ 未找到对应条目，可能文件已被修改。"

    def summarize(self, project: str, extra_paths: Optional[List[str]] = None) -> str:
        """Generate a project summary from all info entries.

        Args:
            project: Project directory name.
            extra_paths: Optional vault-relative paths to include.

        Returns:
            LLM-generated summary.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法汇总。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。

要求：
1. 项目进展时间线（按时间排列关键事件）
2. 当前待解决问题
3. 关键决策和结论
4. 如有外部笔记，融合其内容进行分析

用中文回答，简洁直接。

=== 项目碎片信息 ===
{all_content}
{extra_text}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: generated summary for {project}")
        return response

    def ask(self, project: str, question: str, extra_paths: Optional[List[str]] = None) -> str:
        """Answer a free-form question based on project info entries.

        Args:
            project: Project directory name.
            question: User's question.
            extra_paths: Optional vault-relative paths to include as context.

        Returns:
            LLM-generated answer.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法回答。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 用中文回答，简洁直接
- 如有时间线相关，按时间顺序整理

=== 项目碎片信息 ===
{all_content}
{extra_text}

=== 用户问题 ===
{question}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: answered question for {project}")
        return response

    def search_entries(self, project: str, keyword: str) -> str:
        """Search entries by keyword across all week files.

        Args:
            project: Project directory name.
            keyword: Search term.

        Returns:
            Formatted search results.
        """
        week_files = self._get_week_files(project)
        if not week_files:
            return f"📭 项目 *{project}* 暂无记录。"

        matches: List[str] = []
        kw_lower = keyword.lower()
        for wf in week_files:
            entries = self._parse_entries(wf)
            for e in entries:
                full_text = f"{e.raw_content} {e.comment} {' '.join(e.updates)}"
                if kw_lower in full_text.lower():
                    tag_str = " ".join(e.tags) if e.tags else ""
                    preview = e.raw_content[:80].replace("\n", " ")
                    matches.append(f"• {e.timestamp} {tag_str}\n  {preview}")

        if not matches:
            return f"🔍 在 *{project}* 中未找到包含 \"{keyword}\" 的记录。"

        header = f"🔍 在 *{project}* 中找到 {len(matches)} 条匹配 \"{keyword}\"：\n"
        return header + "\n".join(matches[:10])

    def list_projects(self) -> str:
        """List all project directories.

        Returns:
            Formatted project list.
        """
        if not self.info_dir.exists():
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        projects = sorted(
            d.name for d in self.info_dir.iterdir() if d.is_dir()
        )
        if not projects:
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        lines = [f"📁 共 {len(projects)} 个项目：\n"]
        for p in projects:
            week_files = list((self.info_dir / p).glob("*.md"))
            entry_count = sum(
                len(self._parse_entries(wf)) for wf in week_files
            )
            lines.append(f"• *{p}* — {entry_count} 条记录，{len(week_files)} 个周文件")
        return "\n".join(lines)

    # ── Internal ──────────────────────────────────────────────────────

    def _get_week_file(self, project: str) -> Path:
        """Get the path for the current week's file, creating dir if needed."""
        now = datetime.now(timezone.utc)
        iso_year, iso_week, _ = now.isocalendar()
        filename = f"{iso_year}-W{iso_week:02d}.md"
        project_dir = self.info_dir / project
        project_dir.mkdir(parents=True, exist_ok=True)
        return project_dir / filename

    def _get_week_files(self, project: str) -> List[Path]:
        """Get all week files for a project, sorted newest first."""
        project_dir = self.info_dir / project
        if not project_dir.exists():
            return []
        files = sorted(project_dir.glob("*.md"), reverse=True)
        return files

    def _parse_entries(self, file_path: Path) -> List[InfoEntry]:
        """Parse a weekly Markdown file into structured entries."""
        if not file_path.exists():
            return []

        text = file_path.read_text(encoding="utf-8")
        entries: List[InfoEntry] = []

        # Split by ### headers
        blocks = re.split(r'^(### .+)$', text, flags=re.MULTILINE)

        # blocks = [preamble, header1, body1, header2, body2, ...]
        i = 1
        while i < len(blocks) - 1:
            header_line = blocks[i].strip()
            body = blocks[i + 1]

            # Parse header: ### 2026-03-17 14:30 | #ci #性能
            header_match = re.match(
                r'### (\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s*(?:\|\s*(.*))?$',
                header_line,
            )
            if not header_match:
                i += 2
                continue

            timestamp = header_match.group(1)
            tag_str = header_match.group(2) or ""
            tags = [t.strip() for t in tag_str.split() if t.strip().startswith("#")]

            # Parse body
            raw_lines: List[str] = []
            comment = ""
            updates: List[str] = []

            for line in body.split("\n"):
                stripped = line.strip()
                if stripped.startswith("> "):
                    raw_lines.append(stripped[2:])
                elif stripped.startswith("**点评**:") or stripped.startswith("**点评**: "):
                    comment = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**更新"):
                    updates.append(stripped)

            entries.append(InfoEntry(
                timestamp=timestamp,
                tags=tags,
                raw_content="\n".join(raw_lines),
                comment=comment,
                updates=updates,
            ))
            i += 2

        return entries

    def _load_recent_entries(self, project: str, count: int) -> List[InfoEntry]:
        """Load the most recent N entries across week files."""
        week_files = self._get_week_files(project)
        all_entries: List[InfoEntry] = []
        for wf in week_files:
            entries = self._parse_entries(wf)
            all_entries.extend(reversed(entries))  # newest first within file
            if len(all_entries) >= count:
                break
        return all_entries[:count]

    def _load_all_content(self, project: str) -> str:
        """Load all week files as raw text, oldest first."""
        week_files = self._get_week_files(project)
        parts: List[str] = []
        for wf in reversed(week_files):  # oldest first
            parts.append(f"=== {wf.stem} ===\n")
            parts.append(wf.read_text(encoding="utf-8"))
        return "\n".join(parts)

    def _resolve_content(self, content: str) -> Tuple[str, Optional[str]]:
        """Detect URLs in content and fetch if needed.

        Returns:
            Tuple of (resolved text, source_url or None).
        """
        url_match = _URL_RE.search(content)
        if not url_match:
            return content, None

        url = url_match.group(0)
        # Strip trailing Slack formatting junk
        url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<")

        # If content is mostly just the URL, fetch and replace
        non_url_text = content.replace(url_match.group(0), "").strip()
        try:
            fetched = self._fetch_url(url)
            if non_url_text:
                return f"{non_url_text}\n\n---\n{fetched[:2000]}", url
            return fetched[:2000], url
        except Exception as e:
            logger.warning(f"URL fetch failed for {url}: {e}")
            # Fall back to using the raw content as-is
            return content, url

    def _fetch_url(self, url: str) -> str:
        """Fetch URL content, reusing NoteIngester strategies."""
        if self._note_ingester is None:
            from slack_bot.obsidian.note_ingester import NoteIngester
            # Minimal init just for URL fetching
            self._note_ingester = NoteIngester.__new__(NoteIngester)
            self._note_ingester._MIN_CONTENT_LEN = 50
            NoteIngester._JINA_FIRST_DOMAINS = getattr(
                NoteIngester, '_JINA_FIRST_DOMAINS', set()
            )
            self._note_ingester._JINA_FIRST_DOMAINS = NoteIngester._JINA_FIRST_DOMAINS
        return self._note_ingester._fetch_url(url)

    def _generate_comment_and_tags(
        self, content: str, project: str, context: str
    ) -> Tuple[str, List[str]]:
        """Use LLM to generate a one-line comment and 1-3 tags.

        Args:
            content: The raw information text.
            project: Current project name.
            context: Existing entries in the current week file (for tag consistency).

        Returns:
            Tuple of (comment string, list of tag strings like ["#ci", "#性能"]).
        """
        # Truncate context to avoid prompt bloat
        ctx_preview = context[-1500:] if len(context) > 1500 else context

        prompt = f"""根据以下信息，生成：
1. 一句话点评（不超过50字，直接说判断和建议，不要废话）
2. 1-3 个标签（用 # 前缀，简短，和已有标签保持一致性）

项目名: {project}
已有条目（参考标签风格）:
{ctx_preview}

新信息:
{content}

严格按以下 JSON 格式输出，不要输出其他内容:
{{"comment": "...", "tags": ["#tag1", "#tag2"]}}"""

        try:
            response, _ = self.llm.generate_response(prompt, [])
            # Parse JSON from response
            json_match = re.search(r'\{[^}]+\}', response, re.DOTALL)
            if json_match:
                import json
                data = json.loads(json_match.group(0))
                comment = data.get("comment", "")
                tags = data.get("tags", [])
                # Ensure tags have # prefix
                tags = [t if t.startswith("#") else f"#{t}" for t in tags]
                return comment, tags[:3]
        except Exception as e:
            logger.warning(f"LLM comment/tag generation failed: {e}")

        return "（自动点评生成失败）", []

+                elif stripped.startswith("来源: ") or stripped.startswith("来源:"):

+                    source = stripped.split(":", 1)[1].strip()


删掉 `_resolve_content` 和 `_fetch_url`，替换为 `_extract_source`：


    def _resolve_content(self, content: str) -> Tuple[str, Optional[str]]:
        """Detect URLs in content and fetch if needed.

        Returns:
            Tuple of (resolved text, source_url or None).
        """
        url_match = _URL_RE.search(content)
        if not url_match:
            return content, None

        url = url_match.group(0)
        # Strip trailing Slack formatting junk
        url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<")

        # If content is mostly just the URL, fetch and replace
        non_url_text = content.replace(url_match.group(0), "").strip()
        try:
            fetched = self._fetch_url(url)
            if non_url_text:
                return f"{non_url_text}\n\n---\n{fetched[:2000]}", url
            return fetched[:2000], url
        except Exception as e:
            logger.warning(f"URL fetch failed for {url}: {e}")
            # Fall back to using the raw content as-is
            return content, url

    def _fetch_url(self, url: str) -> str:
        """Fetch URL content, reusing NoteIngester strategies."""
        if self._note_ingester is None:
            from slack_bot.obsidian.note_ingester import NoteIngester
            # Minimal init just for URL fetching
            self._note_ingester = NoteIngester.__new__(NoteIngester)
            self._note_ingester._MIN_CONTENT_LEN = 50
            NoteIngester._JINA_FIRST_DOMAINS = getattr(
                NoteIngester, '_JINA_FIRST_DOMAINS', set()
            )
            self._note_ingester._JINA_FIRST_DOMAINS = NoteIngester._JINA_FIRST_DOMAINS
        return self._note_ingester._fetch_url(url)

    def _generate_comment_and_tags(

    def _extract_source(self, content: str) -> Tuple[str, str]:
        """Extract '(From xxx)' source tag from content.

        Returns:
            Tuple of (cleaned text, source name or empty string).
        """
        match = _SOURCE_RE.search(content)
        if match:
            source = match.group(1).strip()
            text = content[:match.start()].strip()
            return text, source
        return content, ""

    def _generate_comment_and_tags(


    def _extract_source(self, content: str) -> Tuple[str, str]:
        """Extract '(From xxx)' source tag from content.

        Returns:
            Tuple of (cleaned text, source name or empty string).
        """
        match = _SOURCE_RE.search(content)
        if match:
            source = match.group(1).strip()
            text = content[:match.start()].strip()
            return text, source
        return content, ""

    def _generate_comment_and_tags(

"""Project info fragment tracker for Obsidian Bot mode info."""

import re
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple

from pydantic import BaseModel, Field

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM

logger = setup_logger(__name__)

# Match (From xxx) or (from xxx) — case-insensitive, captures the source name
_SOURCE_RE = re.compile(r'\((?:[Ff]rom|FROM)\s+(.+?)\)\s*$')

INFO_SYSTEM_PROMPT = """You are a concise project note-taker.
You help organize and comment on project information fragments.
Always respond in Chinese unless the input is entirely in English."""

class InfoEntry(BaseModel):
    """Single information fragment parsed from a weekly Markdown file."""

    timestamp: str
    tags: List[str] = Field(default_factory=list)
    source: str = ""
    raw_content: str
    comment: str = ""
    updates: List[str] = Field(default_factory=list)
    # Line position in the source file for update insertion
    source_line_start: int = 0
    source_line_end: int = 0

class InfoManager:
    """Manages per-project weekly info files in the Obsidian vault.

    Directory layout::

        vault/info/<project>/YYYY-Www.md
    """

    def __init__(self, vault_path: Path):
        self.vault_path = vault_path
        self.info_dir = vault_path / "info"
        self.llm = GeminiLLM(system_instruction=INFO_SYSTEM_PROMPT)

    # ── Public API ────────────────────────────────────────────────────

    def add_entry(self, project: str, content: str) -> str:
        """Add a new info entry to the current week file.

        Args:
            project: Project directory name.
            content: Raw user input (text with optional '(From xxx)' suffix).

        Returns:
            Slack confirmation message.
        """
        raw_text, source = self._extract_source(content)
        if not raw_text.strip():
            return "⚠ 内容为空，未保存。"

        # Load recent entries for context
        week_file = self._get_week_file(project)
        existing = ""
        if week_file.exists():
            existing = week_file.read_text(encoding="utf-8")

        comment, tags = self._generate_comment_and_tags(raw_text, project, existing)

        now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
        tag_str = " ".join(tags) if tags else ""
        header = f"### {now} | {tag_str}".rstrip()

        lines = [
            header,
            "",
            *[f"> {line}" for line in raw_text.strip().split("\n")],
            "",
        ]
        if source:
            lines.append(f"来源: {source}")
            lines.append("")
        lines.append(f"**点评**: {comment}")
        lines.append("")
        lines.append("---")
        lines.append("")

        entry_text = "\n".join(lines)

        # Ensure directory and file exist
        week_file.parent.mkdir(parents=True, exist_ok=True)
        with open(week_file, "a", encoding="utf-8") as f:
            f.write(entry_text)

        tag_display = f" {tag_str}" if tag_str else ""
        source_display = f"\n📎 来源: {source}" if source else ""
        logger.info(f"InfoManager: added entry to {project}/{week_file.name}{tag_display}")
        return f"✅ 已保存到 *{project}*{tag_display}{source_display}\n\n> {comment}"

    def list_recent(self, project: str, count: int = 5) -> Tuple[str, List[InfoEntry]]:
        """List the most recent entries for a project.

        Args:
            project: Project directory name.
            count: Max entries to show.

        Returns:
            Tuple of (formatted message, entry list).
        """
        entries = self._load_recent_entries(project, count)
        if not entries:
            return f"📭 项目 *{project}* 暂无记录。", []

        lines = [f"📋 *{project}* 最近 {len(entries)} 条信息：\n"]
        for i, e in enumerate(entries, 1):
            tag_str = " ".join(e.tags) if e.tags else ""
            # Truncate raw content for display
            preview = e.raw_content[:60].replace("\n", " ")
            if len(e.raw_content) > 60:
                preview += "..."
            update_count = f" (+{len(e.updates)}更新)" if e.updates else ""
            lines.append(f"*{i}.* {e.timestamp} {tag_str}{update_count}\n   {preview}")
        lines.append("\n输入序号选择要更新的条目，或发送其他内容新增信息。")
        return "\n".join(lines), entries

    def update_entry(
        self, project: str, entries: List[InfoEntry], index: int, content: str
    ) -> str:
        """Append an update to an existing entry.

        Args:
            project: Project directory name.
            entries: Entry list from list_recent.
            index: 0-based index into entries.
            content: Update content from user.

        Returns:
            Slack confirmation message.
        """
        if index < 0 or index >= len(entries):
            return f"⚠ 无效序号，请输入 1-{len(entries)} 之间的数字。"

        entry = entries[index]
        now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        update_line = f"**更新 {now}**: {content.strip()}"

        # Find the source file and insert before the closing ---
        week_files = self._get_week_files(project)
        for wf in week_files:
            file_lines = wf.read_text(encoding="utf-8").split("\n")
            # Match entry by timestamp header
            header_pattern = f"### {entry.timestamp}"
            for i, line in enumerate(file_lines):
                if line.startswith(header_pattern):
                    # Find the next --- separator
                    for j in range(i + 1, len(file_lines)):
                        if file_lines[j].strip() == "---":
                            # Insert update before ---
                            file_lines.insert(j, "")
                            file_lines.insert(j, update_line)
                            wf.write_text("\n".join(file_lines), encoding="utf-8")
                            logger.info(f"InfoManager: updated entry {entry.timestamp} in {project}")
                            return f"✅ 已更新 *{entry.timestamp}* 的记录。\n\n> {update_line}"
                    break

        return "⚠ 未找到对应条目，可能文件已被修改。"

    def summarize(self, project: str, extra_paths: Optional[List[str]] = None) -> str:
        """Generate a project summary from all info entries.

        Args:
            project: Project directory name.
            extra_paths: Optional vault-relative paths to include.

        Returns:
            LLM-generated summary.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法汇总。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。

要求：
1. 项目进展时间线（按时间排列关键事件）
2. 当前待解决问题
3. 关键决策和结论
4. 如有外部笔记，融合其内容进行分析

用中文回答，简洁直接。

=== 项目碎片信息 ===
{all_content}
{extra_text}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: generated summary for {project}")
        return response

    def ask(self, project: str, question: str, extra_paths: Optional[List[str]] = None) -> str:
        """Answer a free-form question based on project info entries.

        Args:
            project: Project directory name.
            question: User's question.
            extra_paths: Optional vault-relative paths to include as context.

        Returns:
            LLM-generated answer.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法回答。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 用中文回答，简洁直接
- 如有时间线相关，按时间顺序整理

=== 项目碎片信息 ===
{all_content}
{extra_text}

=== 用户问题 ===
{question}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: answered question for {project}")
        return response

    def search_entries(self, project: str, keyword: str) -> str:
        """Search entries by keyword across all week files.

        Args:
            project: Project directory name.
            keyword: Search term.

        Returns:
            Formatted search results.
        """
        week_files = self._get_week_files(project)
        if not week_files:
            return f"📭 项目 *{project}* 暂无记录。"

        matches: List[str] = []
        kw_lower = keyword.lower()
        for wf in week_files:
            entries = self._parse_entries(wf)
            for e in entries:
                full_text = f"{e.raw_content} {e.comment} {' '.join(e.updates)}"
                if kw_lower in full_text.lower():
                    tag_str = " ".join(e.tags) if e.tags else ""
                    preview = e.raw_content[:80].replace("\n", " ")
                    matches.append(f"• {e.timestamp} {tag_str}\n  {preview}")

        if not matches:
            return f"🔍 在 *{project}* 中未找到包含 \"{keyword}\" 的记录。"

        header = f"🔍 在 *{project}* 中找到 {len(matches)} 条匹配 \"{keyword}\"：\n"
        return header + "\n".join(matches[:10])

    def list_projects(self) -> str:
        """List all project directories.

        Returns:
            Formatted project list.
        """
        if not self.info_dir.exists():
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        projects = sorted(
            d.name for d in self.info_dir.iterdir() if d.is_dir()
        )
        if not projects:
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        lines = [f"📁 共 {len(projects)} 个项目：\n"]
        for p in projects:
            week_files = list((self.info_dir / p).glob("*.md"))
            entry_count = sum(
                len(self._parse_entries(wf)) for wf in week_files
            )
            lines.append(f"• *{p}* — {entry_count} 条记录，{len(week_files)} 个周文件")
        return "\n".join(lines)

    # ── Internal ──────────────────────────────────────────────────────

    def _get_week_file(self, project: str) -> Path:
        """Get the path for the current week's file, creating dir if needed."""
        now = datetime.now(timezone.utc)
        iso_year, iso_week, _ = now.isocalendar()
        filename = f"{iso_year}-W{iso_week:02d}.md"
        project_dir = self.info_dir / project
        project_dir.mkdir(parents=True, exist_ok=True)
        return project_dir / filename

    def _get_week_files(self, project: str) -> List[Path]:
        """Get all week files for a project, sorted newest first."""
        project_dir = self.info_dir / project
        if not project_dir.exists():
            return []
        files = sorted(project_dir.glob("*.md"), reverse=True)
        return files

    def _parse_entries(self, file_path: Path) -> List[InfoEntry]:
        """Parse a weekly Markdown file into structured entries."""
        if not file_path.exists():
            return []

        text = file_path.read_text(encoding="utf-8")
        entries: List[InfoEntry] = []

        # Split by ### headers
        blocks = re.split(r'^(### .+)$', text, flags=re.MULTILINE)

        # blocks = [preamble, header1, body1, header2, body2, ...]
        i = 1
        while i < len(blocks) - 1:
            header_line = blocks[i].strip()
            body = blocks[i + 1]

            # Parse header: ### 2026-03-17 14:30 | #ci #性能
            header_match = re.match(
                r'### (\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s*(?:\|\s*(.*))?$',
                header_line,
            )
            if not header_match:
                i += 2
                continue

            timestamp = header_match.group(1)
            tag_str = header_match.group(2) or ""
            tags = [t.strip() for t in tag_str.split() if t.strip().startswith("#")]

            # Parse body
            raw_lines: List[str] = []
            comment = ""
            source = ""
            updates: List[str] = []

            for line in body.split("\n"):
                stripped = line.strip()
                if stripped.startswith("> "):
                    raw_lines.append(stripped[2:])
                elif stripped.startswith("来源: ") or stripped.startswith("来源:"):
                    source = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**点评**:") or stripped.startswith("**点评**: "):
                    comment = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**更新"):
                    updates.append(stripped)

            entries.append(InfoEntry(
                timestamp=timestamp,
                tags=tags,
                source=source,
                raw_content="\n".join(raw_lines),
                comment=comment,
                updates=updates,
            ))
            i += 2

        return entries

    def _load_recent_entries(self, project: str, count: int) -> List[InfoEntry]:
        """Load the most recent N entries across week files."""
        week_files = self._get_week_files(project)
        all_entries: List[InfoEntry] = []
        for wf in week_files:
            entries = self._parse_entries(wf)
            all_entries.extend(reversed(entries))  # newest first within file
            if len(all_entries) >= count:
                break
        return all_entries[:count]

    def _load_all_content(self, project: str) -> str:
        """Load all week files as raw text, oldest first."""
        week_files = self._get_week_files(project)
        parts: List[str] = []
        for wf in reversed(week_files):  # oldest first
            parts.append(f"=== {wf.stem} ===\n")
            parts.append(wf.read_text(encoding="utf-8"))
        return "\n".join(parts)

    def _resolve_content(self, content: str) -> Tuple[str, Optional[str]]:
        """Detect URLs in content and fetch if needed.

        Returns:
            Tuple of (resolved text, source_url or None).
        """
        url_match = _URL_RE.search(content)
        if not url_match:
            return content, None

        url = url_match.group(0)
        # Strip trailing Slack formatting junk
        url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<")

        # If content is mostly just the URL, fetch and replace
        non_url_text = content.replace(url_match.group(0), "").strip()
        try:
            fetched = self._fetch_url(url)
            if non_url_text:
                return f"{non_url_text}\n\n---\n{fetched[:2000]}", url
            return fetched[:2000], url
        except Exception as e:
            logger.warning(f"URL fetch failed for {url}: {e}")
            # Fall back to using the raw content as-is
            return content, url

    def _fetch_url(self, url: str) -> str:
        """Fetch URL content, reusing NoteIngester strategies."""
        if self._note_ingester is None:
            from slack_bot.obsidian.note_ingester import NoteIngester
            # Minimal init just for URL fetching
            self._note_ingester = NoteIngester.__new__(NoteIngester)
            self._note_ingester._MIN_CONTENT_LEN = 50
            NoteIngester._JINA_FIRST_DOMAINS = getattr(
                NoteIngester, '_JINA_FIRST_DOMAINS', set()
            )
            self._note_ingester._JINA_FIRST_DOMAINS = NoteIngester._JINA_FIRST_DOMAINS
        return self._note_ingester._fetch_url(url)

    def _generate_comment_and_tags(
        self, content: str, project: str, context: str
    ) -> Tuple[str, List[str]]:
        """Use LLM to generate a one-line comment and 1-3 tags.

        Args:
            content: The raw information text.
            project: Current project name.
            context: Existing entries in the current week file (for tag consistency).

        Returns:
            Tuple of (comment string, list of tag strings like ["#ci", "#性能"]).
        """
        # Truncate context to avoid prompt bloat
        ctx_preview = context[-1500:] if len(context) > 1500 else context

        prompt = f"""根据以下信息，生成：
1. 一句话点评（不超过50字，直接说判断和建议，不要废话）
2. 1-3 个标签（用 # 前缀，简短，和已有标签保持一致性）

项目名: {project}
已有条目（参考标签风格）:
{ctx_preview}

新信息:
{content}

严格按以下 JSON 格式输出，不要输出其他内容:
{{"comment": "...", "tags": ["#tag1", "#tag2"]}}"""

        try:
            response, _ = self.llm.generate_response(prompt, [])
            # Parse JSON from response
            json_match = re.search(r'\{[^}]+\}', response, re.DOTALL)
            if json_match:
                import json
                data = json.loads(json_match.group(0))
                comment = data.get("comment", "")
                tags = data.get("tags", [])
                # Ensure tags have # prefix
                tags = [t if t.startswith("#") else f"#{t}" for t in tags]
                return comment, tags[:3]
        except Exception as e:
            logger.warning(f"LLM comment/tag generation failed: {e}")

        return "（自动点评生成失败）", []

-    def _resolve_content(self, content: str) -> Tuple[str, Optional[str]]:

-        """Detect URLs in content and fetch if needed.

+    def _extract_source(self, content: str) -> Tuple[str, str]:

+        """Extract '(From xxx)' source tag from content.

-            Tuple of (resolved text, source_url or None).

+            Tuple of (cleaned text, source name or empty string).

-        url_match = _URL_RE.search(content)

+        match = _SOURCE_RE.search(content)

+            source = match.group(1).strip()

+            text = content[:match.start()].strip()

-        # Strip trailing Slack formatting junk

-        # If content is mostly just the URL, fetch and replace

-        non_url_text = content.replace(url_match.group(0), "").strip()

-            fetched = self._fetch_url(url)

-                return f"{non_url_text}\n\n---\n{fetched[:2000]}", url

-            logger.warning(f"URL fetch failed for {url}: {e}")

-            # Fall back to using the raw content as-is

-    def _fetch_url(self, url: str) -> str:

-        """Fetch URL content, reusing NoteIngester strategies."""

-        if self._note_ingester is None:

-            from slack_bot.obsidian.note_ingester import NoteIngester

-            # Minimal init just for URL fetching

-            self._note_ingester = NoteIngester.__new__(NoteIngester)

-            self._note_ingester._MIN_CONTENT_LEN = 50

-            NoteIngester._JINA_FIRST_DOMAINS = getattr(

-                NoteIngester, '_JINA_FIRST_DOMAINS', set()

-            self._note_ingester._JINA_FIRST_DOMAINS = NoteIngester._JINA_FIRST_DOMAINS

-        return self._note_ingester._fetch_url(url)


最后更新 `ask` 的 prompt，要求引用日期和来源：


        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 用中文回答，简洁直接
- 如有时间线相关，按时间顺序整理

=== 项目碎片信息 ===
{all_content}
{extra_text}

=== 用户问题 ===
{question}
"""

        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 每个论点必须标注出处：[日期, 来源]（如 [2026-03-17, 张三]），方便人工复核
- 如果条目没有来源字段，只标注日期
- 用中文回答，简洁直接

=== 项目碎片信息 ===
{all_content}
{extra_text}

=== 用户问题 ===
{question}
"""


        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 每个论点必须标注出处：[日期, 来源]（如 [2026-03-17, 张三]），方便人工复核
- 如果条目没有来源字段，只标注日期
- 用中文回答，简洁直接

=== 项目碎片信息 ===
{all_content}
{extra_text}

=== 用户问题 ===
{question}
"""

"""Project info fragment tracker for Obsidian Bot mode info."""

import re
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple

from pydantic import BaseModel, Field

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM

logger = setup_logger(__name__)

# Match (From xxx) or (from xxx) — case-insensitive, captures the source name
_SOURCE_RE = re.compile(r'\((?:[Ff]rom|FROM)\s+(.+?)\)\s*$')

INFO_SYSTEM_PROMPT = """You are a concise project note-taker.
You help organize and comment on project information fragments.
Always respond in Chinese unless the input is entirely in English."""

class InfoEntry(BaseModel):
    """Single information fragment parsed from a weekly Markdown file."""

    timestamp: str
    tags: List[str] = Field(default_factory=list)
    source: str = ""
    raw_content: str
    comment: str = ""
    updates: List[str] = Field(default_factory=list)
    # Line position in the source file for update insertion
    source_line_start: int = 0
    source_line_end: int = 0

class InfoManager:
    """Manages per-project weekly info files in the Obsidian vault.

    Directory layout::

        vault/info/<project>/YYYY-Www.md
    """

    def __init__(self, vault_path: Path):
        self.vault_path = vault_path
        self.info_dir = vault_path / "info"
        self.llm = GeminiLLM(system_instruction=INFO_SYSTEM_PROMPT)

    # ── Public API ────────────────────────────────────────────────────

    def add_entry(self, project: str, content: str) -> str:
        """Add a new info entry to the current week file.

        Args:
            project: Project directory name.
            content: Raw user input (text with optional '(From xxx)' suffix).

        Returns:
            Slack confirmation message.
        """
        raw_text, source = self._extract_source(content)
        if not raw_text.strip():
            return "⚠ 内容为空，未保存。"

        # Load recent entries for context
        week_file = self._get_week_file(project)
        existing = ""
        if week_file.exists():
            existing = week_file.read_text(encoding="utf-8")

        comment, tags = self._generate_comment_and_tags(raw_text, project, existing)

        now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
        tag_str = " ".join(tags) if tags else ""
        header = f"### {now} | {tag_str}".rstrip()

        lines = [
            header,
            "",
            *[f"> {line}" for line in raw_text.strip().split("\n")],
            "",
        ]
        if source:
            lines.append(f"来源: {source}")
            lines.append("")
        lines.append(f"**点评**: {comment}")
        lines.append("")
        lines.append("---")
        lines.append("")

        entry_text = "\n".join(lines)

        # Ensure directory and file exist
        week_file.parent.mkdir(parents=True, exist_ok=True)
        with open(week_file, "a", encoding="utf-8") as f:
            f.write(entry_text)

        tag_display = f" {tag_str}" if tag_str else ""
        source_display = f"\n📎 来源: {source}" if source else ""
        logger.info(f"InfoManager: added entry to {project}/{week_file.name}{tag_display}")
        return f"✅ 已保存到 *{project}*{tag_display}{source_display}\n\n> {comment}"

    def list_recent(self, project: str, count: int = 5) -> Tuple[str, List[InfoEntry]]:
        """List the most recent entries for a project.

        Args:
            project: Project directory name.
            count: Max entries to show.

        Returns:
            Tuple of (formatted message, entry list).
        """
        entries = self._load_recent_entries(project, count)
        if not entries:
            return f"📭 项目 *{project}* 暂无记录。", []

        lines = [f"📋 *{project}* 最近 {len(entries)} 条信息：\n"]
        for i, e in enumerate(entries, 1):
            tag_str = " ".join(e.tags) if e.tags else ""
            # Truncate raw content for display
            preview = e.raw_content[:60].replace("\n", " ")
            if len(e.raw_content) > 60:
                preview += "..."
            update_count = f" (+{len(e.updates)}更新)" if e.updates else ""
            lines.append(f"*{i}.* {e.timestamp} {tag_str}{update_count}\n   {preview}")
        lines.append("\n输入序号选择要更新的条目，或发送其他内容新增信息。")
        return "\n".join(lines), entries

    def update_entry(
        self, project: str, entries: List[InfoEntry], index: int, content: str
    ) -> str:
        """Append an update to an existing entry.

        Args:
            project: Project directory name.
            entries: Entry list from list_recent.
            index: 0-based index into entries.
            content: Update content from user.

        Returns:
            Slack confirmation message.
        """
        if index < 0 or index >= len(entries):
            return f"⚠ 无效序号，请输入 1-{len(entries)} 之间的数字。"

        entry = entries[index]
        now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        update_line = f"**更新 {now}**: {content.strip()}"

        # Find the source file and insert before the closing ---
        week_files = self._get_week_files(project)
        for wf in week_files:
            file_lines = wf.read_text(encoding="utf-8").split("\n")
            # Match entry by timestamp header
            header_pattern = f"### {entry.timestamp}"
            for i, line in enumerate(file_lines):
                if line.startswith(header_pattern):
                    # Find the next --- separator
                    for j in range(i + 1, len(file_lines)):
                        if file_lines[j].strip() == "---":
                            # Insert update before ---
                            file_lines.insert(j, "")
                            file_lines.insert(j, update_line)
                            wf.write_text("\n".join(file_lines), encoding="utf-8")
                            logger.info(f"InfoManager: updated entry {entry.timestamp} in {project}")
                            return f"✅ 已更新 *{entry.timestamp}* 的记录。\n\n> {update_line}"
                    break

        return "⚠ 未找到对应条目，可能文件已被修改。"

    def summarize(self, project: str, extra_paths: Optional[List[str]] = None) -> str:
        """Generate a project summary from all info entries.

        Args:
            project: Project directory name.
            extra_paths: Optional vault-relative paths to include.

        Returns:
            LLM-generated summary.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法汇总。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。

要求：
1. 项目进展时间线（按时间排列关键事件）
2. 当前待解决问题
3. 关键决策和结论
4. 如有外部笔记，融合其内容进行分析

用中文回答，简洁直接。

=== 项目碎片信息 ===
{all_content}
{extra_text}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: generated summary for {project}")
        return response

    def ask(self, project: str, question: str, extra_paths: Optional[List[str]] = None) -> str:
        """Answer a free-form question based on project info entries.

        Args:
            project: Project directory name.
            question: User's question.
            extra_paths: Optional vault-relative paths to include as context.

        Returns:
            LLM-generated answer.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法回答。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 用中文回答，简洁直接
- 如有时间线相关，按时间顺序整理

=== 项目碎片信息 ===
{all_content}
{extra_text}

=== 用户问题 ===
{question}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: answered question for {project}")
        return response

    def search_entries(self, project: str, keyword: str) -> str:
        """Search entries by keyword across all week files.

        Args:
            project: Project directory name.
            keyword: Search term.

        Returns:
            Formatted search results.
        """
        week_files = self._get_week_files(project)
        if not week_files:
            return f"📭 项目 *{project}* 暂无记录。"

        matches: List[str] = []
        kw_lower = keyword.lower()
        for wf in week_files:
            entries = self._parse_entries(wf)
            for e in entries:
                full_text = f"{e.raw_content} {e.comment} {' '.join(e.updates)}"
                if kw_lower in full_text.lower():
                    tag_str = " ".join(e.tags) if e.tags else ""
                    preview = e.raw_content[:80].replace("\n", " ")
                    matches.append(f"• {e.timestamp} {tag_str}\n  {preview}")

        if not matches:
            return f"🔍 在 *{project}* 中未找到包含 \"{keyword}\" 的记录。"

        header = f"🔍 在 *{project}* 中找到 {len(matches)} 条匹配 \"{keyword}\"：\n"
        return header + "\n".join(matches[:10])

    def list_projects(self) -> str:
        """List all project directories.

        Returns:
            Formatted project list.
        """
        if not self.info_dir.exists():
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        projects = sorted(
            d.name for d in self.info_dir.iterdir() if d.is_dir()
        )
        if not projects:
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        lines = [f"📁 共 {len(projects)} 个项目：\n"]
        for p in projects:
            week_files = list((self.info_dir / p).glob("*.md"))
            entry_count = sum(
                len(self._parse_entries(wf)) for wf in week_files
            )
            lines.append(f"• *{p}* — {entry_count} 条记录，{len(week_files)} 个周文件")
        return "\n".join(lines)

    # ── Internal ──────────────────────────────────────────────────────

    def _get_week_file(self, project: str) -> Path:
        """Get the path for the current week's file, creating dir if needed."""
        now = datetime.now(timezone.utc)
        iso_year, iso_week, _ = now.isocalendar()
        filename = f"{iso_year}-W{iso_week:02d}.md"
        project_dir = self.info_dir / project
        project_dir.mkdir(parents=True, exist_ok=True)
        return project_dir / filename

    def _get_week_files(self, project: str) -> List[Path]:
        """Get all week files for a project, sorted newest first."""
        project_dir = self.info_dir / project
        if not project_dir.exists():
            return []
        files = sorted(project_dir.glob("*.md"), reverse=True)
        return files

    def _parse_entries(self, file_path: Path) -> List[InfoEntry]:
        """Parse a weekly Markdown file into structured entries."""
        if not file_path.exists():
            return []

        text = file_path.read_text(encoding="utf-8")
        entries: List[InfoEntry] = []

        # Split by ### headers
        blocks = re.split(r'^(### .+)$', text, flags=re.MULTILINE)

        # blocks = [preamble, header1, body1, header2, body2, ...]
        i = 1
        while i < len(blocks) - 1:
            header_line = blocks[i].strip()
            body = blocks[i + 1]

            # Parse header: ### 2026-03-17 14:30 | #ci #性能
            header_match = re.match(
                r'### (\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s*(?:\|\s*(.*))?$',
                header_line,
            )
            if not header_match:
                i += 2
                continue

            timestamp = header_match.group(1)
            tag_str = header_match.group(2) or ""
            tags = [t.strip() for t in tag_str.split() if t.strip().startswith("#")]

            # Parse body
            raw_lines: List[str] = []
            comment = ""
            source = ""
            updates: List[str] = []

            for line in body.split("\n"):
                stripped = line.strip()
                if stripped.startswith("> "):
                    raw_lines.append(stripped[2:])
                elif stripped.startswith("来源: ") or stripped.startswith("来源:"):
                    source = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**点评**:") or stripped.startswith("**点评**: "):
                    comment = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**更新"):
                    updates.append(stripped)

            entries.append(InfoEntry(
                timestamp=timestamp,
                tags=tags,
                source=source,
                raw_content="\n".join(raw_lines),
                comment=comment,
                updates=updates,
            ))
            i += 2

        return entries

    def _load_recent_entries(self, project: str, count: int) -> List[InfoEntry]:
        """Load the most recent N entries across week files."""
        week_files = self._get_week_files(project)
        all_entries: List[InfoEntry] = []
        for wf in week_files:
            entries = self._parse_entries(wf)
            all_entries.extend(reversed(entries))  # newest first within file
            if len(all_entries) >= count:
                break
        return all_entries[:count]

    def _load_all_content(self, project: str) -> str:
        """Load all week files as raw text, oldest first."""
        week_files = self._get_week_files(project)
        parts: List[str] = []
        for wf in reversed(week_files):  # oldest first
            parts.append(f"=== {wf.stem} ===\n")
            parts.append(wf.read_text(encoding="utf-8"))
        return "\n".join(parts)

    def _extract_source(self, content: str) -> Tuple[str, str]:
        """Extract '(From xxx)' source tag from content.

        Returns:
            Tuple of (cleaned text, source name or empty string).
        """
        match = _SOURCE_RE.search(content)
        if match:
            source = match.group(1).strip()
            text = content[:match.start()].strip()
            return text, source
        return content, ""

    def _generate_comment_and_tags(
        self, content: str, project: str, context: str
    ) -> Tuple[str, List[str]]:
        """Use LLM to generate a one-line comment and 1-3 tags.

        Args:
            content: The raw information text.
            project: Current project name.
            context: Existing entries in the current week file (for tag consistency).

        Returns:
            Tuple of (comment string, list of tag strings like ["#ci", "#性能"]).
        """
        # Truncate context to avoid prompt bloat
        ctx_preview = context[-1500:] if len(context) > 1500 else context

        prompt = f"""根据以下信息，生成：
1. 一句话点评（不超过50字，直接说判断和建议，不要废话）
2. 1-3 个标签（用 # 前缀，简短，和已有标签保持一致性）

项目名: {project}
已有条目（参考标签风格）:
{ctx_preview}

新信息:
{content}

严格按以下 JSON 格式输出，不要输出其他内容:
{{"comment": "...", "tags": ["#tag1", "#tag2"]}}"""

        try:
            response, _ = self.llm.generate_response(prompt, [])
            # Parse JSON from response
            json_match = re.search(r'\{[^}]+\}', response, re.DOTALL)
            if json_match:
                import json
                data = json.loads(json_match.group(0))
                comment = data.get("comment", "")
                tags = data.get("tags", [])
                # Ensure tags have # prefix
                tags = [t if t.startswith("#") else f"#{t}" for t in tags]
                return comment, tags[:3]
        except Exception as e:
            logger.warning(f"LLM comment/tag generation failed: {e}")

        return "（自动点评生成失败）", []

- 只基于提供的信息回答，没有相关信息就直说

+- 每个论点必须标注出处：[日期, 来源]（如 [2026-03-17, 张三]），方便人工复核

+- 如果条目没有来源字段，只标注日期

- 用中文回答，简洁直接

-- 如有时间线相关，按时间顺序整理

=== 项目碎片信息 ===


同样更新 `summary` 的 prompt：


        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。

要求：
1. 项目进展时间线（按时间排列关键事件）
2. 当前待解决问题
3. 关键决策和结论
4. 如有外部笔记，融合其内容进行分析

用中文回答，简洁直接。

=== 项目碎片信息 ===
{all_content}
{extra_text}
"""

        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。

要求：
1. 项目进展时间线（按时间排列关键事件，标注 [日期, 来源]）
2. 当前待解决问题
3. 关键决策和结论
4. 如有外部笔记，融合其内容进行分析

用中文回答，简洁直接。

=== 项目碎片信息 ===
{all_content}
{extra_text}
"""


        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。

要求：
1. 项目进展时间线（按时间排列关键事件，标注 [日期, 来源]）
2. 当前待解决问题
3. 关键决策和结论
4. 如有外部笔记，融合其内容进行分析

用中文回答，简洁直接。

=== 项目碎片信息 ===
{all_content}
{extra_text}
"""

"""Project info fragment tracker for Obsidian Bot mode info."""

import re
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple

from pydantic import BaseModel, Field

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM

logger = setup_logger(__name__)

# Match (From xxx) or (from xxx) — case-insensitive, captures the source name
_SOURCE_RE = re.compile(r'\((?:[Ff]rom|FROM)\s+(.+?)\)\s*$')

INFO_SYSTEM_PROMPT = """You are a concise project note-taker.
You help organize and comment on project information fragments.
Always respond in Chinese unless the input is entirely in English."""

class InfoEntry(BaseModel):
    """Single information fragment parsed from a weekly Markdown file."""

    timestamp: str
    tags: List[str] = Field(default_factory=list)
    source: str = ""
    raw_content: str
    comment: str = ""
    updates: List[str] = Field(default_factory=list)
    # Line position in the source file for update insertion
    source_line_start: int = 0
    source_line_end: int = 0

class InfoManager:
    """Manages per-project weekly info files in the Obsidian vault.

    Directory layout::

        vault/info/<project>/YYYY-Www.md
    """

    def __init__(self, vault_path: Path):
        self.vault_path = vault_path
        self.info_dir = vault_path / "info"
        self.llm = GeminiLLM(system_instruction=INFO_SYSTEM_PROMPT)

    # ── Public API ────────────────────────────────────────────────────

    def add_entry(self, project: str, content: str) -> str:
        """Add a new info entry to the current week file.

        Args:
            project: Project directory name.
            content: Raw user input (text with optional '(From xxx)' suffix).

        Returns:
            Slack confirmation message.
        """
        raw_text, source = self._extract_source(content)
        if not raw_text.strip():
            return "⚠ 内容为空，未保存。"

        # Load recent entries for context
        week_file = self._get_week_file(project)
        existing = ""
        if week_file.exists():
            existing = week_file.read_text(encoding="utf-8")

        comment, tags = self._generate_comment_and_tags(raw_text, project, existing)

        now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
        tag_str = " ".join(tags) if tags else ""
        header = f"### {now} | {tag_str}".rstrip()

        lines = [
            header,
            "",
            *[f"> {line}" for line in raw_text.strip().split("\n")],
            "",
        ]
        if source:
            lines.append(f"来源: {source}")
            lines.append("")
        lines.append(f"**点评**: {comment}")
        lines.append("")
        lines.append("---")
        lines.append("")

        entry_text = "\n".join(lines)

        # Ensure directory and file exist
        week_file.parent.mkdir(parents=True, exist_ok=True)
        with open(week_file, "a", encoding="utf-8") as f:
            f.write(entry_text)

        tag_display = f" {tag_str}" if tag_str else ""
        source_display = f"\n📎 来源: {source}" if source else ""
        logger.info(f"InfoManager: added entry to {project}/{week_file.name}{tag_display}")
        return f"✅ 已保存到 *{project}*{tag_display}{source_display}\n\n> {comment}"

    def list_recent(self, project: str, count: int = 5) -> Tuple[str, List[InfoEntry]]:
        """List the most recent entries for a project.

        Args:
            project: Project directory name.
            count: Max entries to show.

        Returns:
            Tuple of (formatted message, entry list).
        """
        entries = self._load_recent_entries(project, count)
        if not entries:
            return f"📭 项目 *{project}* 暂无记录。", []

        lines = [f"📋 *{project}* 最近 {len(entries)} 条信息：\n"]
        for i, e in enumerate(entries, 1):
            tag_str = " ".join(e.tags) if e.tags else ""
            # Truncate raw content for display
            preview = e.raw_content[:60].replace("\n", " ")
            if len(e.raw_content) > 60:
                preview += "..."
            update_count = f" (+{len(e.updates)}更新)" if e.updates else ""
            lines.append(f"*{i}.* {e.timestamp} {tag_str}{update_count}\n   {preview}")
        lines.append("\n输入序号选择要更新的条目，或发送其他内容新增信息。")
        return "\n".join(lines), entries

    def update_entry(
        self, project: str, entries: List[InfoEntry], index: int, content: str
    ) -> str:
        """Append an update to an existing entry.

        Args:
            project: Project directory name.
            entries: Entry list from list_recent.
            index: 0-based index into entries.
            content: Update content from user.

        Returns:
            Slack confirmation message.
        """
        if index < 0 or index >= len(entries):
            return f"⚠ 无效序号，请输入 1-{len(entries)} 之间的数字。"

        entry = entries[index]
        now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        update_line = f"**更新 {now}**: {content.strip()}"

        # Find the source file and insert before the closing ---
        week_files = self._get_week_files(project)
        for wf in week_files:
            file_lines = wf.read_text(encoding="utf-8").split("\n")
            # Match entry by timestamp header
            header_pattern = f"### {entry.timestamp}"
            for i, line in enumerate(file_lines):
                if line.startswith(header_pattern):
                    # Find the next --- separator
                    for j in range(i + 1, len(file_lines)):
                        if file_lines[j].strip() == "---":
                            # Insert update before ---
                            file_lines.insert(j, "")
                            file_lines.insert(j, update_line)
                            wf.write_text("\n".join(file_lines), encoding="utf-8")
                            logger.info(f"InfoManager: updated entry {entry.timestamp} in {project}")
                            return f"✅ 已更新 *{entry.timestamp}* 的记录。\n\n> {update_line}"
                    break

        return "⚠ 未找到对应条目，可能文件已被修改。"

    def summarize(self, project: str, extra_paths: Optional[List[str]] = None) -> str:
        """Generate a project summary from all info entries.

        Args:
            project: Project directory name.
            extra_paths: Optional vault-relative paths to include.

        Returns:
            LLM-generated summary.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法汇总。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。

要求：
1. 项目进展时间线（按时间排列关键事件）
2. 当前待解决问题
3. 关键决策和结论
4. 如有外部笔记，融合其内容进行分析

用中文回答，简洁直接。

=== 项目碎片信息 ===
{all_content}
{extra_text}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: generated summary for {project}")
        return response

    def ask(self, project: str, question: str, extra_paths: Optional[List[str]] = None) -> str:
        """Answer a free-form question based on project info entries.

        Args:
            project: Project directory name.
            question: User's question.
            extra_paths: Optional vault-relative paths to include as context.

        Returns:
            LLM-generated answer.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法回答。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 每个论点必须标注出处：[日期, 来源]（如 [2026-03-17, 张三]），方便人工复核
- 如果条目没有来源字段，只标注日期
- 用中文回答，简洁直接

=== 项目碎片信息 ===
{all_content}
{extra_text}

=== 用户问题 ===
{question}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: answered question for {project}")
        return response

    def search_entries(self, project: str, keyword: str) -> str:
        """Search entries by keyword across all week files.

        Args:
            project: Project directory name.
            keyword: Search term.

        Returns:
            Formatted search results.
        """
        week_files = self._get_week_files(project)
        if not week_files:
            return f"📭 项目 *{project}* 暂无记录。"

        matches: List[str] = []
        kw_lower = keyword.lower()
        for wf in week_files:
            entries = self._parse_entries(wf)
            for e in entries:
                full_text = f"{e.raw_content} {e.comment} {' '.join(e.updates)}"
                if kw_lower in full_text.lower():
                    tag_str = " ".join(e.tags) if e.tags else ""
                    preview = e.raw_content[:80].replace("\n", " ")
                    matches.append(f"• {e.timestamp} {tag_str}\n  {preview}")

        if not matches:
            return f"🔍 在 *{project}* 中未找到包含 \"{keyword}\" 的记录。"

        header = f"🔍 在 *{project}* 中找到 {len(matches)} 条匹配 \"{keyword}\"：\n"
        return header + "\n".join(matches[:10])

    def list_projects(self) -> str:
        """List all project directories.

        Returns:
            Formatted project list.
        """
        if not self.info_dir.exists():
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        projects = sorted(
            d.name for d in self.info_dir.iterdir() if d.is_dir()
        )
        if not projects:
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        lines = [f"📁 共 {len(projects)} 个项目：\n"]
        for p in projects:
            week_files = list((self.info_dir / p).glob("*.md"))
            entry_count = sum(
                len(self._parse_entries(wf)) for wf in week_files
            )
            lines.append(f"• *{p}* — {entry_count} 条记录，{len(week_files)} 个周文件")
        return "\n".join(lines)

    # ── Internal ──────────────────────────────────────────────────────

    def _get_week_file(self, project: str) -> Path:
        """Get the path for the current week's file, creating dir if needed."""
        now = datetime.now(timezone.utc)
        iso_year, iso_week, _ = now.isocalendar()
        filename = f"{iso_year}-W{iso_week:02d}.md"
        project_dir = self.info_dir / project
        project_dir.mkdir(parents=True, exist_ok=True)
        return project_dir / filename

    def _get_week_files(self, project: str) -> List[Path]:
        """Get all week files for a project, sorted newest first."""
        project_dir = self.info_dir / project
        if not project_dir.exists():
            return []
        files = sorted(project_dir.glob("*.md"), reverse=True)
        return files

    def _parse_entries(self, file_path: Path) -> List[InfoEntry]:
        """Parse a weekly Markdown file into structured entries."""
        if not file_path.exists():
            return []

        text = file_path.read_text(encoding="utf-8")
        entries: List[InfoEntry] = []

        # Split by ### headers
        blocks = re.split(r'^(### .+)$', text, flags=re.MULTILINE)

        # blocks = [preamble, header1, body1, header2, body2, ...]
        i = 1
        while i < len(blocks) - 1:
            header_line = blocks[i].strip()
            body = blocks[i + 1]

            # Parse header: ### 2026-03-17 14:30 | #ci #性能
            header_match = re.match(
                r'### (\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s*(?:\|\s*(.*))?$',
                header_line,
            )
            if not header_match:
                i += 2
                continue

            timestamp = header_match.group(1)
            tag_str = header_match.group(2) or ""
            tags = [t.strip() for t in tag_str.split() if t.strip().startswith("#")]

            # Parse body
            raw_lines: List[str] = []
            comment = ""
            source = ""
            updates: List[str] = []

            for line in body.split("\n"):
                stripped = line.strip()
                if stripped.startswith("> "):
                    raw_lines.append(stripped[2:])
                elif stripped.startswith("来源: ") or stripped.startswith("来源:"):
                    source = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**点评**:") or stripped.startswith("**点评**: "):
                    comment = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**更新"):
                    updates.append(stripped)

            entries.append(InfoEntry(
                timestamp=timestamp,
                tags=tags,
                source=source,
                raw_content="\n".join(raw_lines),
                comment=comment,
                updates=updates,
            ))
            i += 2

        return entries

    def _load_recent_entries(self, project: str, count: int) -> List[InfoEntry]:
        """Load the most recent N entries across week files."""
        week_files = self._get_week_files(project)
        all_entries: List[InfoEntry] = []
        for wf in week_files:
            entries = self._parse_entries(wf)
            all_entries.extend(reversed(entries))  # newest first within file
            if len(all_entries) >= count:
                break
        return all_entries[:count]

    def _load_all_content(self, project: str) -> str:
        """Load all week files as raw text, oldest first."""
        week_files = self._get_week_files(project)
        parts: List[str] = []
        for wf in reversed(week_files):  # oldest first
            parts.append(f"=== {wf.stem} ===\n")
            parts.append(wf.read_text(encoding="utf-8"))
        return "\n".join(parts)

    def _extract_source(self, content: str) -> Tuple[str, str]:
        """Extract '(From xxx)' source tag from content.

        Returns:
            Tuple of (cleaned text, source name or empty string).
        """
        match = _SOURCE_RE.search(content)
        if match:
            source = match.group(1).strip()
            text = content[:match.start()].strip()
            return text, source
        return content, ""

    def _generate_comment_and_tags(
        self, content: str, project: str, context: str
    ) -> Tuple[str, List[str]]:
        """Use LLM to generate a one-line comment and 1-3 tags.

        Args:
            content: The raw information text.
            project: Current project name.
            context: Existing entries in the current week file (for tag consistency).

        Returns:
            Tuple of (comment string, list of tag strings like ["#ci", "#性能"]).
        """
        # Truncate context to avoid prompt bloat
        ctx_preview = context[-1500:] if len(context) > 1500 else context

        prompt = f"""根据以下信息，生成：
1. 一句话点评（不超过50字，直接说判断和建议，不要废话）
2. 1-3 个标签（用 # 前缀，简短，和已有标签保持一致性）

项目名: {project}
已有条目（参考标签风格）:
{ctx_preview}

新信息:
{content}

严格按以下 JSON 格式输出，不要输出其他内容:
{{"comment": "...", "tags": ["#tag1", "#tag2"]}}"""

        try:
            response, _ = self.llm.generate_response(prompt, [])
            # Parse JSON from response
            json_match = re.search(r'\{[^}]+\}', response, re.DOTALL)
            if json_match:
                import json
                data = json.loads(json_match.group(0))
                comment = data.get("comment", "")
                tags = data.get("tags", [])
                # Ensure tags have # prefix
                tags = [t if t.startswith("#") else f"#{t}" for t in tags]
                return comment, tags[:3]
        except Exception as e:
            logger.warning(f"LLM comment/tag generation failed: {e}")

        return "（自动点评生成失败）", []

-1. 项目进展时间线（按时间排列关键事件）

+1. 项目进展时间线（按时间排列关键事件，标注 [日期, 来源]）

4. 如有外部笔记，融合其内容进行分析


更新使用说明，加入来源提示：


                    msg += (
                        "\n\n📌 *使用说明:*\n"
                        "• `project <名称>` — 切换/创建项目\n"
                        "• 直接发送文字或 URL — 新增条目（自动生成点评和标签）\n"
                        "• `update` — 查看最近条目，选序号追加更新\n"
                        "• `ask <问题>` — 基于项目信息回答问题\n"
                        "• `summary` — 汇总项目进展\n"
                        "• `summary + <vault路径>` — 汇总 + 融合外部笔记\n"
                        "• `search <关键词>` — 搜索当前项目\n"
                        "• `list` — 查看所有项目"
                    )

                    msg += (
                        "\n\n📌 *使用说明:*\n"
                        "• `project <名称>` — 切换/创建项目\n"
                        "• 直接发送文字 — 新增条目，末尾加 `(From 来源)` 标注出处\n"
                        "• `update` — 查看最近条目，选序号追加更新\n"
                        "• `ask <问题>` — 基于项目信息回答问题（附带日期和来源）\n"
                        "• `summary` — 汇总项目进展\n"
                        "• `summary + <vault路径>` — 汇总 + 融合外部笔记\n"
                        "• `search <关键词>` — 搜索当前项目\n"
                        "• `list` — 查看所有项目"
                    )