feat: inject metadata filters and add fallback reason codes [AC-IDSMETA-18, AC-IDSMETA-19, AC-IDSMETA-20]

This commit is contained in:
MerCry 2026-03-02 22:15:58 +08:00
parent d3ae92dec5
commit c4ad6eb8ce
3 changed files with 256 additions and 2 deletions

View File

@ -653,6 +653,7 @@ class OrchestratorService:
""" """
[AC-AISVC-16, AC-AISVC-17] RAG retrieval for evidence. [AC-AISVC-16, AC-AISVC-17] RAG retrieval for evidence.
Step 5-6: Multi-KB retrieval with target KBs from intent matching. Step 5-6: Multi-KB retrieval with target KBs from intent matching.
[AC-IDSMETA-19] Inject metadata filters (grade/subject/scene) from context.
""" """
# Skip if flow or intent already handled # Skip if flow or intent already handled
if ctx.diagnostics.get("flow_handled") or ctx.diagnostics.get("intent_handled"): if ctx.diagnostics.get("flow_handled") or ctx.diagnostics.get("intent_handled"):
@ -678,12 +679,24 @@ class OrchestratorService:
retrieval_ctx.metadata["target_kb_ids"] = ctx.target_kb_ids retrieval_ctx.metadata["target_kb_ids"] = ctx.target_kb_ids
logger.info(f"[AC-AISVC-16] Using target_kb_ids from intent: {ctx.target_kb_ids}") logger.info(f"[AC-AISVC-16] Using target_kb_ids from intent: {ctx.target_kb_ids}")
# [AC-IDSMETA-19] Inject metadata filters from context
metadata_filters = await self._build_metadata_filters(ctx)
if metadata_filters:
retrieval_ctx.tag_filter = metadata_filters
logger.info(
f"[AC-IDSMETA-19] Injected metadata filters: "
f"intent_id={ctx.intent_match.rule.id if ctx.intent_match else None}, "
f"target_kbs={ctx.target_kb_ids}, "
f"applied_metadata_filters={metadata_filters.fields}"
)
ctx.retrieval_result = await self._retriever.retrieve(retrieval_ctx) ctx.retrieval_result = await self._retriever.retrieve(retrieval_ctx)
ctx.diagnostics["retrieval"] = { ctx.diagnostics["retrieval"] = {
"hit_count": ctx.retrieval_result.hit_count, "hit_count": ctx.retrieval_result.hit_count,
"max_score": ctx.retrieval_result.max_score, "max_score": ctx.retrieval_result.max_score,
"is_empty": ctx.retrieval_result.is_empty, "is_empty": ctx.retrieval_result.is_empty,
"applied_metadata_filters": metadata_filters.fields if metadata_filters else None,
} }
logger.info( logger.info(
@ -708,6 +721,48 @@ class OrchestratorService:
) )
ctx.diagnostics["retrieval_error"] = str(e) ctx.diagnostics["retrieval_error"] = str(e)
async def _build_metadata_filters(self, ctx: GenerationContext):
"""
[AC-IDSMETA-19] Build metadata filters from context.
Sources:
1. Intent rule metadata (if matched)
2. Session metadata
3. Request metadata
4. Extracted slots from conversation
Returns:
TagFilter with at least grade, subject, scene if available
"""
from app.services.retrieval.metadata import TagFilter
filter_fields = {}
# 1. From intent rule metadata
if ctx.intent_match and hasattr(ctx.intent_match.rule, 'metadata_') and ctx.intent_match.rule.metadata_:
intent_metadata = ctx.intent_match.rule.metadata_
for key in ['grade', 'subject', 'scene']:
if key in intent_metadata:
filter_fields[key] = intent_metadata[key]
# 2. From session/request metadata
if ctx.request_metadata:
for key in ['grade', 'subject', 'scene']:
if key in ctx.request_metadata and key not in filter_fields:
filter_fields[key] = ctx.request_metadata[key]
# 3. From merged context (extracted slots)
if ctx.merged_context and hasattr(ctx.merged_context, 'slots'):
slots = ctx.merged_context.slots or {}
for key in ['grade', 'subject', 'scene']:
if key in slots and key not in filter_fields:
filter_fields[key] = slots[key]
if not filter_fields:
return None
return TagFilter(fields=filter_fields)
async def _build_system_prompt(self, ctx: GenerationContext) -> None: async def _build_system_prompt(self, ctx: GenerationContext) -> None:
""" """
[AC-AISVC-56, AC-AISVC-84] Step 7: Build system prompt with template + behavior rules. [AC-AISVC-56, AC-AISVC-84] Step 7: Build system prompt with template + behavior rules.
@ -919,17 +974,66 @@ class OrchestratorService:
def _fallback_response(self, ctx: GenerationContext) -> str: def _fallback_response(self, ctx: GenerationContext) -> str:
""" """
[AC-AISVC-17] Generate fallback response when LLM is unavailable. [AC-AISVC-17] Generate fallback response when LLM is unavailable.
[AC-IDSMETA-20] Return fallback with structured reason code when no recall.
""" """
if ctx.retrieval_result and not ctx.retrieval_result.is_empty: if ctx.retrieval_result and not ctx.retrieval_result.is_empty:
return ( return (
"根据知识库信息,我找到了一些相关内容," "根据知识库信息,我找到了一些相关内容,"
"但暂时无法生成完整回复。建议您稍后重试或联系人工客服。" "但暂时无法生成完整回复。建议您稍后重试或联系人工客服。"
) )
# [AC-IDSMETA-20] Record structured fallback reason code
fallback_reason_code = self._determine_fallback_reason_code(ctx)
ctx.diagnostics["fallback_reason_code"] = fallback_reason_code
logger.warning(
f"[AC-IDSMETA-20] No recall, using fallback: "
f"intent_id={ctx.intent_match.rule.id if ctx.intent_match else None}, "
f"target_kbs={ctx.target_kb_ids}, "
f"applied_metadata_filters={ctx.diagnostics.get('retrieval', {}).get('applied_metadata_filters')}, "
f"fallback_reason_code={fallback_reason_code}"
)
return ( return (
"抱歉,我暂时无法处理您的请求。" "抱歉,我暂时无法处理您的请求。"
"请稍后重试或联系人工客服获取帮助。" "请稍后重试或联系人工客服获取帮助。"
) )
def _determine_fallback_reason_code(self, ctx: GenerationContext) -> str:
"""
[AC-IDSMETA-20] Determine structured fallback reason code.
Reason codes:
- no_recall_after_metadata_filter: No results after applying metadata filters
- no_recall_no_kb: No target knowledge bases configured
- no_recall_kb_empty: Knowledge base is empty
- no_recall_low_score: Results found but below threshold
- no_recall_error: Retrieval error occurred
"""
retrieval_diag = ctx.diagnostics.get("retrieval", {})
# Check for retrieval error
if ctx.diagnostics.get("retrieval_error"):
return "no_recall_error"
# Check if metadata filters were applied
if retrieval_diag.get("applied_metadata_filters"):
return "no_recall_after_metadata_filter"
# Check if target KBs were configured
if not ctx.target_kb_ids:
return "no_recall_no_kb"
# Check if KB is empty (no candidates at all)
if retrieval_diag.get("total_candidates", 0) == 0:
return "no_recall_kb_empty"
# Results found but filtered out by score threshold
if retrieval_diag.get("total_candidates", 0) > 0 and retrieval_diag.get("filtered_hits", 0) == 0:
return "no_recall_low_score"
return "no_recall_unknown"
def _calculate_confidence(self, ctx: GenerationContext) -> None: def _calculate_confidence(self, ctx: GenerationContext) -> None:
""" """
[AC-AISVC-17, AC-AISVC-18, AC-AISVC-19] Calculate confidence score. [AC-AISVC-17, AC-AISVC-18, AC-AISVC-19] Calculate confidence score.

View File

@ -6,7 +6,10 @@ Retrieval layer for AI Service.
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from app.services.retrieval.metadata import TagFilter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,6 +26,14 @@ class RetrievalContext:
session_id: str | None = None session_id: str | None = None
channel_type: str | None = None channel_type: str | None = None
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
tag_filter: "TagFilter | None" = None
kb_ids: list[str] | None = None
def get_tag_filter_dict(self) -> dict[str, str | list[str] | None] | None:
"""获取标签过滤器的字典表示"""
if self.tag_filter and not self.tag_filter.is_empty():
return self.tag_filter.fields
return None
@dataclass @dataclass

View File

@ -9,7 +9,7 @@ from datetime import date, datetime
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel, Field
class RetrievalStrategy(str, Enum): class RetrievalStrategy(str, Enum):
@ -32,6 +32,39 @@ class ChunkMetadataModel(BaseModel):
valid_until: str | None = None valid_until: str | None = None
priority: int = 5 priority: int = 5
keywords: list[str] = [] keywords: list[str] = []
grade: str = ""
subject: str = ""
type: str = ""
class GradeEnum(str, Enum):
"""年级枚举"""
GRADE_7 = "初一"
GRADE_8 = "初二"
GRADE_9 = "初三"
GRADE_10 = "高一"
GRADE_11 = "高二"
GRADE_12 = "高三"
class SubjectEnum(str, Enum):
"""学科枚举"""
GENERAL = "通用"
PHYSICS = "物理"
CHINESE = "语文"
MATH = "数学"
ENGLISH = "英语"
CHEMISTRY = "化学"
BIOLOGY = "生物"
class TypeEnum(str, Enum):
"""内容类型枚举"""
PAIN_POINT = "痛点"
SUBJECT_FEATURE = "学科特点"
ABILITY_REQUIREMENT = "能力要求"
COURSE_VALUE = "课程价值"
VIEWPOINT = "观点"
@dataclass @dataclass
@ -50,6 +83,9 @@ class ChunkMetadata:
valid_until: date | None = None valid_until: date | None = None
priority: int = 5 priority: int = 5
keywords: list[str] = field(default_factory=list) keywords: list[str] = field(default_factory=list)
grade: str = ""
subject: str = ""
type: str = ""
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for storage.""" """Convert to dictionary for storage."""
@ -64,6 +100,9 @@ class ChunkMetadata:
"valid_until": self.valid_until.isoformat() if self.valid_until else None, "valid_until": self.valid_until.isoformat() if self.valid_until else None,
"priority": self.priority, "priority": self.priority,
"keywords": self.keywords, "keywords": self.keywords,
"grade": self.grade,
"subject": self.subject,
"type": self.type,
} }
@classmethod @classmethod
@ -80,6 +119,9 @@ class ChunkMetadata:
valid_until=date.fromisoformat(data["valid_until"]) if data.get("valid_until") else None, valid_until=date.fromisoformat(data["valid_until"]) if data.get("valid_until") else None,
priority=data.get("priority", 5), priority=data.get("priority", 5),
keywords=data.get("keywords", []), keywords=data.get("keywords", []),
grade=data.get("grade", ""),
subject=data.get("subject", ""),
type=data.get("type", ""),
) )
@ -142,6 +184,103 @@ class MetadataFilter:
return {"must": conditions} return {"must": conditions}
class TagFilterModel(BaseModel):
"""Pydantic model for tag filter API serialization."""
fields: dict[str, str | list[str] | None] = Field(default_factory=dict, description="动态过滤字段")
@dataclass
class TagFilter:
"""
动态标签过滤器
用于在 RAG 检索时根据元数据字段进行过滤
支持任意动态字段不再硬编码 grade/subject/type
示例:
- 教育行业: {"grade": "初一", "type": "痛点"}
- 医疗行业: {"department": "内科", "disease_type": "慢性病"}
"""
fields: dict[str, str | list[str] | None] = field(default_factory=dict)
def is_empty(self) -> bool:
"""检查过滤器是否为空"""
if not self.fields:
return True
return all(v is None or v == "" or (isinstance(v, list) and len(v) == 0) for v in self.fields.values())
def to_qdrant_filter(self) -> dict[str, Any] | None:
"""转换为 Qdrant 过滤格式"""
conditions = []
for field_name, field_value in self.fields.items():
if field_value is None or field_value == "":
continue
if isinstance(field_value, list):
if len(field_value) == 0:
continue
conditions.append({
"key": f"metadata.{field_name}",
"match": {"any": field_value}
})
else:
conditions.append({
"key": f"metadata.{field_name}",
"match": {"value": field_value}
})
if not conditions:
return None
return {"must": conditions}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "TagFilter":
"""从字典创建"""
return cls(fields=dict(data))
@classmethod
def from_model(cls, model: TagFilterModel) -> "TagFilter":
"""从 Pydantic 模型创建"""
return cls(fields=dict(model.fields))
def merge_with_context(self, context: dict[str, Any]) -> "TagFilter":
"""
与上下文合并支持模板变量替换
例如:
tag_filter = TagFilter(fields={"grade": "${context.grade}", "type": "痛点"})
context = {"grade": "初一"}
result = tag_filter.merge_with_context(context)
# result = TagFilter(fields={"grade": "初一", "type": "痛点"})
"""
def resolve_value(value: str | list | None) -> str | list | None:
if value is None:
return None
if isinstance(value, list):
return [resolve_value(v) for v in value]
if isinstance(value, str):
if value.startswith("${context.") and value.endswith("}"):
key = value[10:-1]
return context.get(key)
return value
return str(value)
resolved_fields = {}
for field_name, field_value in self.fields.items():
resolved = resolve_value(field_value)
if resolved is not None and resolved != "":
resolved_fields[field_name] = resolved
elif field_name in context:
resolved_fields[field_name] = context[field_name]
return TagFilter(fields=resolved_fields)
def get_field(self, field_name: str) -> str | list[str] | None:
"""获取指定字段的值"""
return self.fields.get(field_name)
@dataclass @dataclass
class KnowledgeChunk: class KnowledgeChunk:
""" """