from __future__ import annotations

import asyncio
import hashlib
import json
import math
import re
from dataclasses import dataclass
from time import perf_counter

from sqlalchemy import or_, select
from sqlalchemy.orm import Session

from app.core.config import get_settings
from app.services.chat_context_service import ChatContextService
from app.models.knowledge import (
    Knowledge,
    KnowledgeChunk,
    KnowledgeManifest,
    KnowledgeRetrievalCandidate,
    KnowledgeRetrievalLog,
    KnowledgeSection,
    KnowledgeVersion,
)
from app.services.knowledge_pipeline_service import (
    expand_synonyms,
    extract_terms,
    is_numbered_heading_title,
    is_practice_anchor_title,
    practice_titles_related,
)
from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService
from app.services.knowledge_service import KnowledgeScope
from app.services.model_service import _call_configured_model, _system_config_bool
from app.services.model_routing_service import ModelRoutingService
from app.services.rag_service import PromptService, RagResult, RetrievedChunk

SAFETY_RULE_VERSION = "minimum-safety-v1"
MAX_LEXICAL_CANDIDATES = 12
MAX_SELECTED_SECTIONS = 4
MAX_OVERVIEW_LEXICAL_CANDIDATES = 48
MAX_OVERVIEW_SELECTED_SECTIONS = 24
BUSINESS_MARKERS = {
    "课程", "大本营", "训练营", "老师", "卢慧", "功课", "作业", "学员",
    "课堂", "练习", "静心", "觉察", "内在",
}
FIXED_INFORMATION_TYPE = "fixed"
BUSINESS_TYPE_PRIORITY = {"course": 3, "qa": 2, "general": 1, "fixed": 0}
FIXED_INFORMATION_MARKERS = {
    "上课时间", "上课安排", "开课时间", "带练", "回放", "音频", "课程作业", "作业",
    "会议", "会议链接", "直播链接", "链接", "服务权益", "权益", "课程助理", "助理",
    "联系方式", "联系谁", "当前安排", "最新安排", "什么时候上课", "几点上课",
}
_ROUTING_STOP_TERMS = {
    "课程", "练习", "功课", "作业", "静心", "具体", "详细", "内容", "怎么", "操作", "什么", "方法",
}


@dataclass
class Candidate:
    chunk: KnowledgeChunk
    section: KnowledgeSection
    knowledge: Knowledge
    version: KnowledgeVersion
    lexical_score: float
    rerank_score: float | None = None
    selected: bool = False
    discard_reason: str | None = None


class KnowledgeAgentService:
    @classmethod
    async def build_result(
        cls,
        db: Session,
        *,
        question: str,
        history=None,
        session_summary: str | None = None,
        summary_up_to_message_id: int | None = None,
        session_id: int | None = None,
        user_id: int | None = None,
        version_overrides: dict[int, int] | None = None,
        preview_knowledge_ids: list[int] | None = None,
        context_trace: list[dict] | None = None,
        prompt_override: str | None = None,
        response_depth: int | None = None,
        growth_context: str | None = None,
        topic_context: str | None = None,
        product_context: str | None = None,
    ) -> RagResult:
        started = perf_counter()
        catalog = cls.get_knowledge_catalog(
            db, version_overrides=version_overrides, preview_knowledge_ids=preview_knowledge_ids
        )
        catalog_json = json.dumps(catalog, ensure_ascii=False, sort_keys=True, default=str)
        log = KnowledgeRetrievalLog(
            session_id=session_id,
            user_id=user_id,
            question=question.strip(),
            catalog_snapshot=catalog_json,
            catalog_hash=hashlib.sha256(catalog_json.encode()).hexdigest(),
            safety_rule_version=SAFETY_RULE_VERSION,
            status="running",
        )
        db.add(log)
        db.flush()
        scopes = [KnowledgeScope(id=item["knowledgeId"], name=item["name"], feishu_space_id="", feishu_node_id="") for item in catalog]
        trace: list[dict] = list(context_trace or [])
        trace.append(cls._trace("get_knowledge_catalog", len(trace) + 1, {}, {"items": catalog, "count": len(catalog)}, started))
        retrieval_question, rewrite_trace = await cls._rewrite_question(
            db,
            question,
            history=history,
            session_summary=session_summary,
            summary_up_to_message_id=summary_up_to_message_id,
            started=started,
            order=len(trace) + 1,
        )
        trace.append(rewrite_trace)
        need_knowledge, selected_ids, reason = cls._decide(retrieval_question, catalog)
        terms = cls._query_terms(retrieval_question)
        title_routes = cls._route_knowledge_by_titles(db, terms, catalog)
        if title_routes:
            need_knowledge = True
            selected_ids = cls._prioritize_knowledge_ids(
                [item["knowledgeId"] for item in title_routes] + selected_ids,
                catalog,
                prefer_fixed_information=cls._is_fixed_information_query(retrieval_question),
            )[:4]
            reason = "章节标题直接命中知识库"
        trace.append(cls._trace(
            "agent_decision",
            len(trace) + 1,
            {"question": retrieval_question, "originalQuestion": question},
            {"needKnowledge": need_knowledge, "selectedKnowledgeIds": selected_ids, "reason": reason},
            started,
        ))
        trace.append(cls._trace(
            "route_knowledge_by_titles",
            len(trace) + 1,
            {"queryTerms": terms},
            {"count": len(title_routes), "matches": title_routes},
            started,
        ))
        chunks: list[RetrievedChunk] = []
        try:
            if need_knowledge and selected_ids:
                practice_overview = cls._is_practice_overview(retrieval_question)
                candidate_limit = cls._candidate_limit(retrieval_question)
                candidates = cls.search_knowledge(
                    db,
                    terms,
                    selected_ids,
                    catalog,
                    candidate_limit=candidate_limit,
                    prefer_fixed_information=cls._is_fixed_information_query(retrieval_question),
                )
                trace.append(cls._trace("search_knowledge", len(trace) + 1, {"queryTerms": terms, "knowledgeIds": selected_ids, "candidateLimit": candidate_limit, "preferFixedInformation": cls._is_fixed_information_query(retrieval_question)}, {"candidateCount": len(candidates), "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
                await cls._rerank(db, retrieval_question, candidates, trace, started)
                selected = cls._select_sections(
                    candidates,
                    limit=cls._selection_limit(retrieval_question),
                    prefer_numbered_practice=practice_overview,
                    prefer_fixed_information=cls._is_fixed_information_query(retrieval_question),
                )
                if practice_overview:
                    selected.sort(key=lambda item: (
                        item.knowledge.knowledge_type != FIXED_INFORMATION_TYPE,
                        -cls._version_timestamp(item.version),
                        item.knowledge.name,
                        item.section.sort_order,
                    ))
                selected_contents = {
                    item.section.id: cls._protect_content(
                        cls._read_complete_section(db, item.section),
                        max_chars=900 if practice_overview else 6000,
                    )
                    for item in selected
                }
                chunks = [
                    RetrievedChunk(
                        knowledge_id=item.knowledge.id,
                        knowledge_name=item.knowledge.name,
                        title=item.section.title,
                        content=selected_contents[item.section.id],
                        version_id=item.version.id,
                        section_id=item.section.id,
                        chunk_id=item.chunk.id,
                        score=item.rerank_score if item.rerank_score is not None else item.lexical_score,
                        knowledge_type=item.knowledge.knowledge_type,
                        published_at=(item.version.published_at or item.version.created_at).isoformat(),
                    )
                    for item in selected
                ]
                trace.append(cls._trace("read_knowledge_sections", len(trace) + 1, {"sectionIds": [item.section.id for item in selected]}, {"count": len(selected), "sections": [{"knowledgeId": x.knowledge.id, "knowledgeName": x.knowledge.name, "sectionId": x.section.id, "title": x.section.title, "content": selected_contents[x.section.id], "adopted": True} for x in selected]}, started))
                cls._persist_candidates(db, log.id, candidates)
                log.knowledge_called = 1
                log.selected_knowledge_ids = ",".join(str(item) for item in selected_ids)
                log.query_terms = json.dumps(terms, ensure_ascii=False)
                log.final_section_ids = ",".join(str(item.section.id) for item in selected)
            log.tool_trace = _dump_trace(trace)
            log.tool_cost_ms = int((perf_counter() - started) * 1000)
            log.status = "prepared"
            db.add(log)
            messages = PromptService.build_messages(
                db,
                question,
                chunks,
                history,
                session_summary,
                summary_up_to_message_id,
                prompt_override=prompt_override,
                response_depth=response_depth,
                growth_context=growth_context,
                topic_context=topic_context,
                product_context=product_context,
            )
            return RagResult(
                question=question,
                knowledge_scopes=scopes,
                chunks=chunks,
                prompt=PromptService.render_messages(messages),
                allow_general_knowledge=not need_knowledge,
                retrieval_log_id=log.id,
                tool_trace=trace,
                messages=messages,
            )
        except Exception as exc:
            log.status = "failed"
            log.error_message = str(exc)
            log.tool_trace = _dump_trace(trace)
            log.tool_cost_ms = int((perf_counter() - started) * 1000)
            db.add(log)
            raise

    @classmethod
    async def _rewrite_question(
        cls,
        db: Session,
        question: str,
        *,
        history,
        session_summary: str | None,
        summary_up_to_message_id: int | None,
        started: float,
        order: int,
    ) -> tuple[str, dict]:
        recent, summary = ChatContextService.select_memory(
            db,
            history,
            session_summary,
            summary_up_to_message_id,
        )
        if not recent or not cls._needs_contextual_rewrite(question):
            return question, cls._trace(
                "rewrite_contextual_question",
                order,
                {"question": question, "historyMessageCount": len(recent)},
                {"rewrittenQuestion": question, "mode": "not_needed", "count": 1},
                started,
            )

        history_text = ChatContextService.messages_text(recent)
        request = {
            "question": question,
            "summary": summary or "",
            "recentHistory": history_text,
        }
        fallback = cls._fallback_rewrite(question, recent)
        model = ModelRoutingService.default_model(db)
        if model is None or _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled):
            return fallback, cls._trace(
                "rewrite_contextual_question",
                order,
                request,
                {"rewrittenQuestion": fallback, "mode": "deterministic_fallback", "count": 1},
                started,
                status="fallback",
            )

        prompt = (
            "你是对话问题改写器。结合历史，把当前追问改写成可独立理解、适合知识检索的完整问题。"
            "不得回答问题，不得添加历史中没有的事实，只输出改写后的一个问题。\n\n"
            f"历史摘要：\n{summary or '无'}\n\n最近对话：\n{history_text}\n\n当前追问：\n{question}"
        )
        rag = RagResult(question=question, knowledge_scopes=[], chunks=[], prompt=prompt, allow_general_knowledge=True)
        try:
            rewritten = (await asyncio.to_thread(
                _call_configured_model,
                model,
                rag,
                allow_no_hit=True,
            )).strip().strip('"“”')
            if not rewritten or len(rewritten) > 500:
                raise ValueError("问题改写结果为空或过长")
            return rewritten, cls._trace(
                "rewrite_contextual_question",
                order,
                request,
                {"rewrittenQuestion": rewritten, "mode": "model", "count": 1},
                started,
            )
        except Exception as exc:
            return fallback, cls._trace(
                "rewrite_contextual_question",
                order,
                request,
                {"rewrittenQuestion": fallback, "mode": "deterministic_fallback", "count": 1},
                started,
                status="fallback",
                error=str(exc)[:300],
            )

    @staticmethod
    def _needs_contextual_rewrite(question: str) -> bool:
        text = question.strip()
        if len(text) <= 16:
            return True
        if re.search(r"(上面|前面|之前|刚才|此前|前一个|原来).{0,8}(问题|内容|回答|练习|流程)", text):
            return True
        if re.search(r"(再|重新|继续).{0,6}(回答|解释|说明|分析|看看)", text):
            return True
        return bool(re.search(r"(这个|那个|上述|上面|前面|其中|第二|继续|然后呢|怎么办|怎么做|什么意思|为什么)$", text))

    @staticmethod
    def _fallback_rewrite(question: str, history) -> str:
        previous_users = [
            message.content.strip()
            for message in history
            if message.role == "user" and message.content.strip()
        ][-3:]
        if not previous_users:
            return question.strip()
        context = "\n".join(f"历史问题{index + 1}：{content}" for index, content in enumerate(previous_users))
        return f"结合以下历史问题回答当前追问：\n{context}\n当前追问：{question.strip()}"

    @staticmethod
    def get_knowledge_catalog(
        db: Session,
        *,
        version_overrides: dict[int, int] | None = None,
        preview_knowledge_ids: list[int] | None = None,
    ) -> list[dict]:
        cacheable = version_overrides is None and preview_knowledge_ids is None
        if cacheable:
            return KnowledgeCatalogCacheService.get_or_build("open", lambda: KnowledgeAgentService._catalog_from_db(db, None, None))
        return KnowledgeAgentService._catalog_from_db(db, version_overrides, preview_knowledge_ids)

    @staticmethod
    def _catalog_from_db(db: Session, version_overrides: dict[int, int] | None, preview_knowledge_ids: list[int] | None) -> list[dict]:
        query = select(Knowledge).where(Knowledge.lifecycle_status == "active")
        if preview_knowledge_ids is None:
            query = query.where(
                Knowledge.status == 1,
                Knowledge.current_version_id.is_not(None),
                Knowledge.manifest_confirmed == 1,
                Knowledge.source_status == "normal",
            )
        elif preview_knowledge_ids:
            query = query.where(Knowledge.id.in_(preview_knowledge_ids))
        rows = db.scalars(query.order_by(Knowledge.id)).all()
        catalog: list[dict] = []
        for knowledge in rows:
            version_id = (version_overrides or {}).get(knowledge.id) or knowledge.current_version_id or knowledge.pending_version_id
            if not version_id:
                continue
            version = db.get(KnowledgeVersion, version_id)
            manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version_id))
            if version is None or manifest is None:
                continue
            catalog.append(
                {
                    "knowledgeId": knowledge.id,
                    "name": knowledge.name,
                    "type": knowledge.knowledge_type,
                    "purpose": manifest.purpose,
                    "applicableQuestions": manifest.applicable_questions,
                    "inapplicableQuestions": manifest.inapplicable_questions,
                    "coreTopics": manifest.core_topics,
                    "boundaries": manifest.boundaries,
                    "versionId": version.id,
                    "versionNo": version.version_no,
                    "publishedAt": version.published_at,
                }
            )
        return catalog

    @staticmethod
    def _trace(tool: str, order: int, request: dict, response: dict, started: float, *, status: str = "success", error: str | None = None, retries: int = 0) -> dict:
        now = int((perf_counter() - started) * 1000)
        return {"tool": tool, "order": order, "request": request, "response": response, **response, "status": status, "startedAtMs": now, "endedAtMs": now, "durationMs": 0, "responseCount": response.get("count", response.get("candidateCount")), "error": error, "retries": retries}

    @staticmethod
    def _candidate_trace(item: Candidate) -> dict:
        return {"knowledgeId": item.knowledge.id, "knowledgeName": item.knowledge.name, "knowledgeType": item.knowledge.knowledge_type, "knowledgeTypePriority": _knowledge_type_priority_label(item.knowledge.knowledge_type), "versionId": item.version.id, "chunkId": item.chunk.id, "sectionId": item.section.id, "title": item.chunk.title, "lexicalScore": item.lexical_score, "rerankScore": item.rerank_score, "selected": item.selected, "discardReason": item.discard_reason}

    @classmethod
    def search_knowledge(
        cls,
        db: Session,
        terms: list[str],
        selected_ids: list[int],
        catalog: list[dict],
        *,
        candidate_limit: int = MAX_LEXICAL_CANDIDATES,
        prefer_fixed_information: bool = False,
    ) -> list[Candidate]:
        versions_by_kb = {item["knowledgeId"]: item["versionId"] for item in catalog}
        version_ids = [versions_by_kb[item] for item in selected_ids if item in versions_by_kb]
        if not version_ids:
            return []
        chunks = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.version_id.in_(version_ids))).all()
        sections = {item.id: item for item in db.scalars(select(KnowledgeSection).where(KnowledgeSection.version_id.in_(version_ids))).all()}
        knowledge_rows = {item.id: item for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(selected_ids))).all()}
        versions = {item.id: item for item in db.scalars(select(KnowledgeVersion).where(KnowledgeVersion.id.in_(version_ids))).all()}
        frequencies = {term: sum(1 for chunk in chunks if term in f"{chunk.title}{chunk.normalized_text}") for term in terms}
        candidates: list[Candidate] = []
        for chunk in chunks:
            section, knowledge, version = sections.get(chunk.section_id), knowledge_rows.get(chunk.knowledge_id), versions.get(chunk.version_id)
            if not section or not knowledge or not version:
                continue
            haystack = f"{knowledge.name} {chunk.title} {chunk.normalized_text} {chunk.content}".lower()
            score = 0.0
            for term in terms:
                count = haystack.count(term)
                if count:
                    idf = math.log((len(chunks) + 1) / (frequencies.get(term, 0) + 1)) + 1
                    score += (count / (count + 1.2)) * idf * (2.4 if term in chunk.title.lower() else 1.0)
            score += cls._title_intent_boost(chunk.title, terms)
            if score > 0 and prefer_fixed_information and knowledge.knowledge_type == FIXED_INFORMATION_TYPE:
                score += 12.0
            if score > 0:
                candidates.append(Candidate(chunk, section, knowledge, version, score))
        candidates.sort(
            key=lambda item: (
                prefer_fixed_information and item.knowledge.knowledge_type == FIXED_INFORMATION_TYPE,
                item.lexical_score,
                item.version.published_at or item.version.created_at,
            ),
            reverse=True,
        )
        return candidates[:candidate_limit]

    @classmethod
    async def _rerank(cls, db: Session, question: str, candidates: list[Candidate], trace: list[dict], started: float) -> None:
        if not candidates:
            return
        model = ModelRoutingService.default_model(db)
        if model is None or _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled):
            trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "mode": "lexical_fallback", "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
            return
        prompt = (
            "你是知识检索重排器。根据用户问题给候选打0到100相关分，只返回JSON："
            '{"scores":[{"index":1,"score":90}]}。不要回答问题。\n'
            f"用户问题：{question}\n候选：\n"
            + "\n".join(f"[{index}] {item.knowledge.name}/{item.chunk.title}: {item.chunk.content[:350]}" for index, item in enumerate(candidates, 1))
        )
        rag = RagResult(question=question, knowledge_scopes=[], chunks=[], prompt=prompt, allow_general_knowledge=True)
        try:
            raw = await asyncio.to_thread(_call_configured_model, model, rag, allow_no_hit=True)
            payload = json.loads(_extract_json(raw))
            score_map = {int(item["index"]): max(0.0, min(100.0, float(item["score"]))) for item in payload.get("scores", [])}
            for index, item in enumerate(candidates, 1):
                item.rerank_score = score_map.get(index)
            candidates.sort(key=lambda item: item.rerank_score if item.rerank_score is not None else item.lexical_score, reverse=True)
            trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
        except Exception as exc:
            trace.append(cls._trace("Rerank", len(trace) + 1, {"candidateCount": len(candidates)}, {"count": len(candidates), "mode": "lexical_fallback"}, started, status="fallback", error=str(exc)[:300]))

    @staticmethod
    def _select_sections(
        candidates: list[Candidate],
        *,
        limit: int = MAX_SELECTED_SECTIONS,
        prefer_numbered_practice: bool = False,
        prefer_fixed_information: bool = False,
    ) -> list[Candidate]:
        if prefer_numbered_practice:
            numbered_candidates = [
                item for item in candidates
                if KnowledgeAgentService._is_numbered_practice_title(item.chunk.title)
                or (prefer_fixed_information and item.knowledge.knowledge_type == FIXED_INFORMATION_TYPE)
            ]
            if numbered_candidates:
                for item in candidates:
                    keep_fixed = prefer_fixed_information and item.knowledge.knowledge_type == FIXED_INFORMATION_TYPE
                    if not KnowledgeAgentService._is_numbered_practice_title(item.chunk.title) and not keep_fixed:
                        item.discard_reason = "作业清单优先采用编号练习章节"
                candidates = numbered_candidates
            candidates = sorted(
                candidates,
                key=lambda item: (
                    KnowledgeAgentService._is_numbered_practice_title(item.chunk.title),
                    item.rerank_score if item.rerank_score is not None else item.lexical_score,
                ),
                reverse=True,
            )
        if prefer_fixed_information:
            candidates = sorted(
                candidates,
                key=lambda item: (
                    item.knowledge.knowledge_type == FIXED_INFORMATION_TYPE,
                    item.rerank_score if item.rerank_score is not None else item.lexical_score,
                    item.version.published_at or item.version.created_at,
                ),
                reverse=True,
            )
        selected: list[Candidate] = []
        seen: set[int] = set()
        for item in candidates:
            score = item.rerank_score if item.rerank_score is not None else item.lexical_score * 10
            if score < 8:
                item.discard_reason = "相关性不足"
            elif item.section.id in seen:
                item.discard_reason = "同一父章节已有更高分候选"
            elif len(selected) >= limit:
                item.discard_reason = "超过本轮章节数量限制"
            else:
                item.selected = True
                item.discard_reason = None
                selected.append(item)
                seen.add(item.section.id)
        return selected

    @staticmethod
    def _selection_limit(question: str) -> int:
        if KnowledgeAgentService._is_practice_overview(question):
            return MAX_OVERVIEW_SELECTED_SECTIONS
        return MAX_SELECTED_SECTIONS

    @staticmethod
    def _candidate_limit(question: str) -> int:
        if KnowledgeAgentService._is_practice_overview(question):
            return MAX_OVERVIEW_LEXICAL_CANDIDATES
        return MAX_LEXICAL_CANDIDATES

    @staticmethod
    def _is_practice_overview(question: str) -> bool:
        if "追问：" in question:
            follow_up = question.rsplit("追问：", 1)[-1]
            if any(marker in follow_up for marker in ("具体", "详细", "怎么做", "步骤", "方法", "注意", "内容")):
                return False
        overview_markers = ("有哪些", "是什么", "包括什么", "都有什么", "列出", "汇总", "总结")
        practice_markers = ("作业", "功课", "练习")
        return any(marker in question for marker in practice_markers) and any(marker in question for marker in overview_markers)

    @staticmethod
    def _is_numbered_practice_title(title: str) -> bool:
        return bool(re.search(r"(?:练习|作业|功课)\s*[一二三四五六七八九十百\d]+", title))

    @staticmethod
    def _title_intent_boost(title: str, terms: list[str]) -> float:
        if not {"作业", "功课", "练习"}.intersection(terms):
            return 0.0
        if KnowledgeAgentService._is_numbered_practice_title(title):
            return 20.0
        if any(marker in title for marker in ("练习", "作业", "功课")):
            return 3.0
        return 0.0

    @staticmethod
    def _read_complete_section(db: Session, section: KnowledgeSection) -> str:
        """Read a heading together with its lower-level child sections."""
        rows = db.scalars(
            select(KnowledgeSection)
            .where(
                KnowledgeSection.version_id == section.version_id,
            )
            .order_by(KnowledgeSection.sort_order)
        ).all()
        selected_index = next((index for index, row in enumerate(rows) if row.id == section.id), None)
        if selected_index is None:
            return section.content

        for anchor_index, row in enumerate(rows):
            if not is_practice_anchor_title(row.title):
                continue
            end_index = anchor_index + 1
            while end_index < len(rows):
                next_title = rows[end_index].title
                if is_practice_anchor_title(next_title):
                    break
                if is_numbered_heading_title(next_title) and not practice_titles_related(row.title, next_title):
                    break
                end_index += 1
            if anchor_index <= selected_index < end_index:
                return "\n\n".join(item.content for item in rows[anchor_index:end_index])

        level = _heading_level(section.content)
        if level is None:
            return section.content
        parts: list[str] = []
        for row in rows[selected_index:]:
            row_level = _heading_level(row.content)
            if row.id != section.id and row_level is not None and row_level <= level:
                break
            parts.append(row.content)
        return "\n\n".join(parts)

    @staticmethod
    def _persist_candidates(db: Session, log_id: int, candidates: list[Candidate]) -> None:
        for item in candidates:
            db.add(KnowledgeRetrievalCandidate(
                retrieval_log_id=log_id,
                knowledge_id=item.knowledge.id,
                version_id=item.version.id,
                chunk_id=item.chunk.id,
                section_id=item.section.id,
                lexical_score=f"{item.lexical_score:.6f}",
                rerank_score=f"{item.rerank_score:.4f}" if item.rerank_score is not None else None,
                selected=1 if item.selected else 0,
                discard_reason=item.discard_reason,
            ))

    @staticmethod
    def _decide(question: str, catalog: list[dict]) -> tuple[bool, list[int], str]:
        business = any(marker in question for marker in BUSINESS_MARKERS)
        fixed_information = KnowledgeAgentService._is_fixed_information_query(question)
        if not catalog:
            return business or fixed_information, [], "当前没有可用正式知识库"
        terms = set(extract_terms(question))
        ranked: list[tuple[int, float, str, int]] = []
        for item in catalog:
            manifest_terms = set(extract_terms(" ".join(str(item.get(key, "")) for key in ("name", "purpose", "applicableQuestions", "coreTopics"))))
            overlap = len(terms & manifest_terms) / max(1, min(len(terms), 18))
            is_fixed = item.get("type") == FIXED_INFORMATION_TYPE
            if overlap > 0 or business or (fixed_information and is_fixed):
                if fixed_information:
                    type_priority = 4 if is_fixed else BUSINESS_TYPE_PRIORITY.get(str(item.get("type")), 0)
                elif business:
                    type_priority = BUSINESS_TYPE_PRIORITY.get(str(item.get("type")), 0)
                else:
                    type_priority = 0
                ranked.append((type_priority, overlap, str(item.get("publishedAt") or ""), int(item["knowledgeId"])))
        ranked.sort(reverse=True)
        selected = [
            item_id for type_priority, score, _, item_id in ranked
            if (fixed_information and type_priority == 4) or score >= 0.04
        ][:4]
        if business and not selected:
            selected = [item_id for _, _, _, item_id in ranked[:3]]
        if fixed_information and selected:
            return True, selected, "涉及当前安排，固定信息类知识库优先"
        if business:
            return True, selected, "涉及课程/老师/业务知识"
        if selected:
            return True, selected, "问题与知识目录主题相关"
        return False, [], "问题与当前知识目录无关，无需调用知识库"

    @staticmethod
    def _is_fixed_information_query(question: str) -> bool:
        return any(marker in question for marker in FIXED_INFORMATION_MARKERS)

    @staticmethod
    def _prioritize_knowledge_ids(
        knowledge_ids: list[int],
        catalog: list[dict],
        *,
        prefer_fixed_information: bool,
    ) -> list[int]:
        unique_ids = list(dict.fromkeys(knowledge_ids))
        if not prefer_fixed_information:
            return unique_ids
        catalog_by_id = {int(item["knowledgeId"]): item for item in catalog}
        return sorted(
            unique_ids,
            key=lambda knowledge_id: (
                catalog_by_id.get(knowledge_id, {}).get("type") == FIXED_INFORMATION_TYPE,
                str(catalog_by_id.get(knowledge_id, {}).get("publishedAt") or ""),
            ),
            reverse=True,
        )

    @staticmethod
    def _version_timestamp(version: KnowledgeVersion) -> float:
        value = version.published_at or version.created_at
        return value.timestamp() if value is not None else 0.0

    @staticmethod
    def _route_knowledge_by_titles(db: Session, terms: list[str], catalog: list[dict]) -> list[dict]:
        """Map a named exercise/chapter back to its knowledge base before retrieval."""
        version_ids = [int(item["versionId"]) for item in catalog if item.get("versionId")]
        if not version_ids:
            return []
        distinctive_terms = list(dict.fromkeys(
            term.lower() for term in terms
            if len(term) >= 2 and term not in _ROUTING_STOP_TERMS
        ))
        if not distinctive_terms:
            return []
        distinctive_terms = sorted(distinctive_terms, key=len, reverse=True)[:12]

        rows = db.execute(
            select(KnowledgeChunk.knowledge_id, KnowledgeChunk.title)
            .where(
                KnowledgeChunk.version_id.in_(version_ids),
                or_(*(KnowledgeChunk.title.contains(term) for term in distinctive_terms)),
            )
        ).all()
        best_by_knowledge: dict[int, tuple[float, str]] = {}
        for knowledge_id, title in rows:
            normalized_title = title.lower()
            score = sum(min(len(term), 4) ** 2 for term in distinctive_terms if term in normalized_title)
            current = best_by_knowledge.get(knowledge_id)
            if score > 0 and (current is None or score > current[0]):
                best_by_knowledge[knowledge_id] = (float(score), title)

        ranked = sorted(best_by_knowledge.items(), key=lambda item: (item[1][0], item[0]), reverse=True)
        return [
            {"knowledgeId": knowledge_id, "matchedTitle": title, "score": score}
            for knowledge_id, (score, title) in ranked[:4]
        ]

    @staticmethod
    def _query_terms(question: str) -> list[str]:
        base = extract_terms(question)
        return list(dict.fromkeys(base + expand_synonyms(base)))[:40]

    @staticmethod
    def _protect_content(content: str, *, max_chars: int = 6000) -> str:
        return content if len(content) <= max_chars else content[:max_chars].rstrip() + "\n[章节内容已按保护规则截断]"


def _dump_trace(trace: list[dict]) -> str:
    """Persist tool traces without failing on database datetime values."""
    return json.dumps(trace, ensure_ascii=False, default=_json_trace_value)


def _json_trace_value(value):
    isoformat = getattr(value, "isoformat", None)
    if callable(isoformat):
        return isoformat()
    return str(value)


def _knowledge_type_priority_label(value: str) -> str:
    return {
        "fixed": "最高优先级：固定信息类冲突时优先",
        "course": "课程内容优先于答疑和通用知识",
        "qa": "过往答疑参考，不覆盖课程原文",
        "general": "背景补充，不覆盖业务知识",
    }.get(value, "背景补充")


def _heading_level(content: str) -> int | None:
    match = re.match(r"^(#{1,6})\s+", content.lstrip())
    return len(match.group(1)) if match else None


def _extract_json(value: str) -> str:
    match = re.search(r"\{[\s\S]*\}", value)
    if not match:
        raise ValueError("重排模型未返回 JSON")
    return match.group(0)
