diff --git a/ai-service/app/api/admin/__init__.py b/ai-service/app/api/admin/__init__.py index 5bc4b10..d444b7f 100644 --- a/ai-service/app/api/admin/__init__.py +++ b/ai-service/app/api/admin/__init__.py @@ -6,10 +6,12 @@ Admin API routes for AI Service management. from app.api.admin.api_key import router as api_key_router from app.api.admin.dashboard import router as dashboard_router from app.api.admin.embedding import router as embedding_router +from app.api.admin.intent_rules import router as intent_rules_router from app.api.admin.kb import router as kb_router from app.api.admin.llm import router as llm_router +from app.api.admin.prompt_templates import router as prompt_templates_router from app.api.admin.rag import router as rag_router from app.api.admin.sessions import router as sessions_router from app.api.admin.tenants import router as tenants_router -__all__ = ["api_key_router", "dashboard_router", "embedding_router", "kb_router", "llm_router", "rag_router", "sessions_router", "tenants_router"] +__all__ = ["api_key_router", "dashboard_router", "embedding_router", "intent_rules_router", "kb_router", "llm_router", "prompt_templates_router", "rag_router", "sessions_router", "tenants_router"] diff --git a/ai-service/app/api/admin/prompt_templates.py b/ai-service/app/api/admin/prompt_templates.py new file mode 100644 index 0000000..1bbc605 --- /dev/null +++ b/ai-service/app/api/admin/prompt_templates.py @@ -0,0 +1,208 @@ +""" +Prompt Template Management API. +[AC-AISVC-52, AC-AISVC-57, AC-AISVC-58, AC-AISVC-54, AC-AISVC-55] Prompt template CRUD and publish/rollback endpoints. +""" + +import logging +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, Header, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_session +from app.models.entities import PromptTemplateCreate, PromptTemplateUpdate +from app.services.prompt.template_service import PromptTemplateService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/prompt-templates", tags=["Prompt Management"]) + + +def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str: + """Extract tenant ID from header.""" + if not x_tenant_id: + raise HTTPException(status_code=400, detail="X-Tenant-Id header is required") + return x_tenant_id + + +@router.get("") +async def list_templates( + tenant_id: str = Depends(get_tenant_id), + scene: str | None = None, + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """ + [AC-AISVC-57] List all prompt templates for a tenant. + """ + logger.info(f"[AC-AISVC-57] Listing prompt templates for tenant={tenant_id}, scene={scene}") + + service = PromptTemplateService(session) + templates = await service.list_templates(tenant_id, scene) + + data = [] + for t in templates: + published_version = await service.get_published_version_info(tenant_id, t.id) + data.append({ + "id": str(t.id), + "name": t.name, + "scene": t.scene, + "description": t.description, + "is_default": t.is_default, + "published_version": published_version, + "created_at": t.created_at.isoformat(), + "updated_at": t.updated_at.isoformat(), + }) + + return {"data": data} + + +@router.post("", status_code=201) +async def create_template( + body: PromptTemplateCreate, + tenant_id: str = Depends(get_tenant_id), + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """ + [AC-AISVC-52] Create a new prompt template. + """ + logger.info(f"[AC-AISVC-52] Creating prompt template for tenant={tenant_id}, name={body.name}") + + service = PromptTemplateService(session) + template = await service.create_template(tenant_id, body) + + return { + "id": str(template.id), + "name": template.name, + "scene": template.scene, + "description": template.description, + "is_default": template.is_default, + "created_at": template.created_at.isoformat(), + "updated_at": template.updated_at.isoformat(), + } + + +@router.get("/{tpl_id}") +async def get_template_detail( + tpl_id: uuid.UUID, + tenant_id: str = Depends(get_tenant_id), + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """ + [AC-AISVC-58] Get prompt template detail with version history. + """ + logger.info(f"[AC-AISVC-58] Getting template detail for tenant={tenant_id}, id={tpl_id}") + + service = PromptTemplateService(session) + detail = await service.get_template_detail(tenant_id, tpl_id) + + if not detail: + raise HTTPException(status_code=404, detail="Template not found") + + return detail + + +@router.put("/{tpl_id}") +async def update_template( + tpl_id: uuid.UUID, + body: PromptTemplateUpdate, + tenant_id: str = Depends(get_tenant_id), + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """ + [AC-AISVC-53] Update prompt template (creates a new version). + """ + logger.info(f"[AC-AISVC-53] Updating template for tenant={tenant_id}, id={tpl_id}") + + service = PromptTemplateService(session) + template = await service.update_template(tenant_id, tpl_id, body) + + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + published_version = await service.get_published_version_info(tenant_id, template.id) + + return { + "id": str(template.id), + "name": template.name, + "scene": template.scene, + "description": template.description, + "is_default": template.is_default, + "published_version": published_version, + "created_at": template.created_at.isoformat(), + "updated_at": template.updated_at.isoformat(), + } + + +@router.post("/{tpl_id}/publish") +async def publish_template( + tpl_id: uuid.UUID, + body: dict[str, int], + tenant_id: str = Depends(get_tenant_id), + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """ + [AC-AISVC-54] Publish a specific version of the template. + """ + version = body.get("version") + if version is None: + raise HTTPException(status_code=400, detail="version is required") + + logger.info( + f"[AC-AISVC-54] Publishing template version for tenant={tenant_id}, " + f"id={tpl_id}, version={version}" + ) + + service = PromptTemplateService(session) + success = await service.publish_version(tenant_id, tpl_id, version) + + if not success: + raise HTTPException(status_code=404, detail="Template or version not found") + + return {"success": True, "message": f"Version {version} published successfully"} + + +@router.post("/{tpl_id}/rollback") +async def rollback_template( + tpl_id: uuid.UUID, + body: dict[str, int], + tenant_id: str = Depends(get_tenant_id), + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """ + [AC-AISVC-55] Rollback to a specific historical version. + """ + version = body.get("version") + if version is None: + raise HTTPException(status_code=400, detail="version is required") + + logger.info( + f"[AC-AISVC-55] Rolling back template for tenant={tenant_id}, " + f"id={tpl_id}, version={version}" + ) + + service = PromptTemplateService(session) + success = await service.rollback_version(tenant_id, tpl_id, version) + + if not success: + raise HTTPException(status_code=404, detail="Template or version not found") + + return {"success": True, "message": f"Rolled back to version {version} successfully"} + + +@router.delete("/{tpl_id}", status_code=204) +async def delete_template( + tpl_id: uuid.UUID, + tenant_id: str = Depends(get_tenant_id), + session: AsyncSession = Depends(get_session), +) -> None: + """ + Delete a prompt template and all its versions. + """ + logger.info(f"Deleting template for tenant={tenant_id}, id={tpl_id}") + + service = PromptTemplateService(session) + success = await service.delete_template(tenant_id, tpl_id) + + if not success: + raise HTTPException(status_code=404, detail="Template not found") diff --git a/ai-service/app/main.py b/ai-service/app/main.py index 56d7ccc..b377b26 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from app.api import chat_router, health_router -from app.api.admin import api_key_router, dashboard_router, embedding_router, kb_router, llm_router, rag_router, sessions_router, tenants_router +from app.api.admin import api_key_router, dashboard_router, embedding_router, intent_rules_router, kb_router, llm_router, prompt_templates_router, rag_router, sessions_router, tenants_router from app.api.admin.kb_optimized import router as kb_optimized_router from app.core.config import get_settings from app.core.database import close_db, init_db @@ -130,9 +130,11 @@ app.include_router(chat_router) app.include_router(api_key_router) app.include_router(dashboard_router) app.include_router(embedding_router) +app.include_router(intent_rules_router) app.include_router(kb_router) app.include_router(kb_optimized_router) app.include_router(llm_router) +app.include_router(prompt_templates_router) app.include_router(rag_router) app.include_router(sessions_router) app.include_router(tenants_router) diff --git a/ai-service/app/models/entities.py b/ai-service/app/models/entities.py index dd9363a..7c70f3c 100644 --- a/ai-service/app/models/entities.py +++ b/ai-service/app/models/entities.py @@ -118,20 +118,34 @@ class Tenant(SQLModel, table=True): updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time") +class KBType(str, Enum): + PRODUCT = "product" + FAQ = "faq" + SCRIPT = "script" + POLICY = "policy" + GENERAL = "general" + + class KnowledgeBase(SQLModel, table=True): """ - [AC-ASA-01] Knowledge base entity with tenant isolation. + [AC-ASA-01, AC-AISVC-59] Knowledge base entity with tenant isolation. + [v0.6.0] Extended with kb_type, priority, is_enabled, doc_count for multi-KB management. """ __tablename__ = "knowledge_bases" __table_args__ = ( Index("ix_knowledge_bases_tenant_id", "tenant_id"), + Index("ix_knowledge_bases_tenant_kb_type", "tenant_id", "kb_type"), ) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True) name: str = Field(..., description="Knowledge base name") + kb_type: str = Field(default=KBType.GENERAL.value, description="Knowledge base type: product/faq/script/policy/general") description: str | None = Field(default=None, description="Knowledge base description") + priority: int = Field(default=0, ge=0, description="Priority weight, higher value means higher priority") + is_enabled: bool = Field(default=True, description="Whether the knowledge base is enabled") + doc_count: int = Field(default=0, ge=0, description="Document count (cached statistic)") created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time") updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time") @@ -184,9 +198,20 @@ class IndexJob(SQLModel, table=True): class KnowledgeBaseCreate(SQLModel): """Schema for creating a new knowledge base.""" - tenant_id: str name: str + kb_type: str = KBType.GENERAL.value description: str | None = None + priority: int = 0 + + +class KnowledgeBaseUpdate(SQLModel): + """Schema for updating a knowledge base.""" + + name: str | None = None + kb_type: str | None = None + description: str | None = None + priority: int | None = None + is_enabled: bool | None = None class DocumentCreate(SQLModel): @@ -222,3 +247,181 @@ class ApiKeyCreate(SQLModel): key: str name: str is_active: bool = True + + +class TemplateVersionStatus(str, Enum): + DRAFT = "draft" + PUBLISHED = "published" + ARCHIVED = "archived" + + +class PromptTemplate(SQLModel, table=True): + """ + [AC-AISVC-51, AC-AISVC-52] Prompt template entity with tenant isolation. + Main table for storing template metadata. + """ + + __tablename__ = "prompt_templates" + __table_args__ = ( + Index("ix_prompt_templates_tenant_scene", "tenant_id", "scene"), + Index("ix_prompt_templates_tenant_id", "tenant_id"), + ) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True) + name: str = Field(..., description="Template name, e.g., 'Default Customer Service Persona'") + scene: str = Field(..., description="Scene tag: chat/rag_qa/greeting/farewell") + description: str | None = Field(default=None, description="Template description") + is_default: bool = Field(default=False, description="Whether this is the default template for the scene") + created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time") + updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time") + + +class PromptTemplateVersion(SQLModel, table=True): + """ + [AC-AISVC-53] Prompt template version entity. + Stores versioned content with status management. + """ + + __tablename__ = "prompt_template_versions" + __table_args__ = ( + Index("ix_template_versions_template_status", "template_id", "status"), + ) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + template_id: uuid.UUID = Field(..., description="Foreign key to prompt_templates.id", foreign_key="prompt_templates.id", index=True) + version: int = Field(..., description="Version number (auto-incremented per template)") + status: str = Field(default=TemplateVersionStatus.DRAFT.value, description="Version status: draft/published/archived") + system_instruction: str = Field(..., description="System instruction content with {{variable}} placeholders") + variables: list[dict[str, Any]] | None = Field( + default=None, + sa_column=Column("variables", JSON, nullable=True), + description="Variable definitions, e.g., [{'name': 'persona_name', 'default': '小N', 'description': '人设名称'}]" + ) + created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time") + + +class PromptTemplateCreate(SQLModel): + """Schema for creating a new prompt template.""" + + name: str + scene: str + description: str | None = None + system_instruction: str + variables: list[dict[str, Any]] | None = None + is_default: bool = False + + +class PromptTemplateUpdate(SQLModel): + """Schema for updating a prompt template.""" + + name: str | None = None + scene: str | None = None + description: str | None = None + system_instruction: str | None = None + variables: list[dict[str, Any]] | None = None + is_default: bool | None = None + + +class ResponseType(str, Enum): + """[AC-AISVC-65] Response type for intent rules.""" + FIXED = "fixed" + RAG = "rag" + FLOW = "flow" + TRANSFER = "transfer" + + +class IntentRule(SQLModel, table=True): + """ + [AC-AISVC-65] Intent rule entity with tenant isolation. + Supports keyword and regex matching for intent recognition. + """ + + __tablename__ = "intent_rules" + __table_args__ = ( + Index("ix_intent_rules_tenant_enabled_priority", "tenant_id", "is_enabled"), + Index("ix_intent_rules_tenant_id", "tenant_id"), + ) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + tenant_id: str = Field(..., description="Tenant ID for multi-tenant isolation", index=True) + name: str = Field(..., description="Intent name, e.g., 'Return Intent'") + keywords: list[str] | None = Field( + default=None, + sa_column=Column("keywords", JSON, nullable=True), + description="Keyword list for matching, e.g., ['return', 'refund']" + ) + patterns: list[str] | None = Field( + default=None, + sa_column=Column("patterns", JSON, nullable=True), + description="Regex pattern list for matching, e.g., ['return.*goods', 'how to return']" + ) + priority: int = Field(default=0, description="Priority (higher value = higher priority)") + response_type: str = Field(..., description="Response type: fixed/rag/flow/transfer") + target_kb_ids: list[str] | None = Field( + default=None, + sa_column=Column("target_kb_ids", JSON, nullable=True), + description="Target knowledge base IDs for rag type" + ) + flow_id: uuid.UUID | None = Field(default=None, description="Flow ID for flow type") + fixed_reply: str | None = Field(default=None, description="Fixed reply content for fixed type") + transfer_message: str | None = Field(default=None, description="Transfer message for transfer type") + is_enabled: bool = Field(default=True, description="Whether the rule is enabled") + hit_count: int = Field(default=0, description="Hit count for statistics") + created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time") + updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time") + + +class IntentRuleCreate(SQLModel): + """[AC-AISVC-65] Schema for creating a new intent rule.""" + + name: str + keywords: list[str] | None = None + patterns: list[str] | None = None + priority: int = 0 + response_type: str + target_kb_ids: list[str] | None = None + flow_id: str | None = None + fixed_reply: str | None = None + transfer_message: str | None = None + + +class IntentRuleUpdate(SQLModel): + """[AC-AISVC-67] Schema for updating an intent rule.""" + + name: str | None = None + keywords: list[str] | None = None + patterns: list[str] | None = None + priority: int | None = None + response_type: str | None = None + target_kb_ids: list[str] | None = None + flow_id: str | None = None + fixed_reply: str | None = None + transfer_message: str | None = None + is_enabled: bool | None = None + + +class IntentMatchResult: + """ + [AC-AISVC-69] Result of intent matching. + Contains the matched rule and match details. + """ + + def __init__( + self, + rule: IntentRule, + match_type: str, + matched: str, + ): + self.rule = rule + self.match_type = match_type + self.matched = matched + + def to_dict(self) -> dict[str, Any]: + return { + "rule_id": str(self.rule.id), + "rule_name": self.rule.name, + "match_type": self.match_type, + "matched": self.matched, + "response_type": self.rule.response_type, + } diff --git a/ai-service/app/services/prompt/__init__.py b/ai-service/app/services/prompt/__init__.py new file mode 100644 index 0000000..bb29d34 --- /dev/null +++ b/ai-service/app/services/prompt/__init__.py @@ -0,0 +1,9 @@ +""" +Prompt template services for AI Service. +[AC-AISVC-51~AC-AISVC-58] Database-driven prompt template system. +""" + +from app.services.prompt.template_service import PromptTemplateService +from app.services.prompt.variable_resolver import VariableResolver + +__all__ = ["PromptTemplateService", "VariableResolver"] diff --git a/ai-service/app/services/prompt/template_service.py b/ai-service/app/services/prompt/template_service.py new file mode 100644 index 0000000..4e21ff9 --- /dev/null +++ b/ai-service/app/services/prompt/template_service.py @@ -0,0 +1,414 @@ +""" +Prompt template service for AI Service. +[AC-AISVC-51~AC-AISVC-58] Template CRUD, version management, publish/rollback, and caching. +""" + +import logging +import time +import uuid +from datetime import datetime +from typing import Any, Sequence + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col + +from app.models.entities import ( + PromptTemplate, + PromptTemplateVersion, + PromptTemplateCreate, + PromptTemplateUpdate, + TemplateVersionStatus, +) +from app.services.prompt.variable_resolver import VariableResolver +from app.core.prompts import SYSTEM_PROMPT + +logger = logging.getLogger(__name__) + +CACHE_TTL_SECONDS = 300 + + +class TemplateCache: + """ + [AC-AISVC-51] In-memory cache for published templates. + Key: (tenant_id, scene) + Value: (template_version, cached_at) + TTL: 300 seconds + """ + + def __init__(self, ttl_seconds: int = CACHE_TTL_SECONDS): + self._cache: dict[tuple[str, str], tuple[PromptTemplateVersion, float]] = {} + self._ttl = ttl_seconds + + def get(self, tenant_id: str, scene: str) -> PromptTemplateVersion | None: + """Get cached template version if not expired.""" + key = (tenant_id, scene) + if key in self._cache: + version, cached_at = self._cache[key] + if time.time() - cached_at < self._ttl: + return version + else: + del self._cache[key] + return None + + def set(self, tenant_id: str, scene: str, version: PromptTemplateVersion) -> None: + """Cache a template version.""" + key = (tenant_id, scene) + self._cache[key] = (version, time.time()) + + def invalidate(self, tenant_id: str, scene: str | None = None) -> None: + """Invalidate cache for a tenant (optionally for a specific scene).""" + if scene: + key = (tenant_id, scene) + if key in self._cache: + del self._cache[key] + else: + keys_to_delete = [k for k in self._cache if k[0] == tenant_id] + for key in keys_to_delete: + del self._cache[key] + + +_template_cache = TemplateCache() + + +class PromptTemplateService: + """ + [AC-AISVC-52~AC-AISVC-58] Service for managing prompt templates. + + Features: + - Template CRUD with tenant isolation + - Version management (auto-create new version on update) + - Publish/rollback functionality + - In-memory caching with TTL + - Fallback to hardcoded SYSTEM_PROMPT + """ + + def __init__(self, session: AsyncSession): + self._session = session + self._cache = _template_cache + + async def create_template( + self, + tenant_id: str, + create_data: PromptTemplateCreate, + ) -> PromptTemplate: + """ + [AC-AISVC-52] Create a new prompt template with initial version. + """ + template = PromptTemplate( + tenant_id=tenant_id, + name=create_data.name, + scene=create_data.scene, + description=create_data.description, + is_default=create_data.is_default, + ) + self._session.add(template) + await self._session.flush() + + initial_version = PromptTemplateVersion( + template_id=template.id, + version=1, + status=TemplateVersionStatus.DRAFT.value, + system_instruction=create_data.system_instruction, + variables=create_data.variables, + ) + self._session.add(initial_version) + await self._session.flush() + + logger.info( + f"[AC-AISVC-52] Created prompt template: tenant={tenant_id}, " + f"id={template.id}, name={template.name}" + ) + return template + + async def list_templates( + self, + tenant_id: str, + scene: str | None = None, + ) -> Sequence[PromptTemplate]: + """ + [AC-AISVC-57] List templates for a tenant, optionally filtered by scene. + """ + stmt = select(PromptTemplate).where( + PromptTemplate.tenant_id == tenant_id + ) + + if scene: + stmt = stmt.where(PromptTemplate.scene == scene) + + stmt = stmt.order_by(col(PromptTemplate.created_at).desc()) + result = await self._session.execute(stmt) + return result.scalars().all() + + async def get_template( + self, + tenant_id: str, + template_id: uuid.UUID, + ) -> PromptTemplate | None: + """ + [AC-AISVC-58] Get template by ID with tenant isolation. + """ + stmt = select(PromptTemplate).where( + PromptTemplate.tenant_id == tenant_id, + PromptTemplate.id == template_id, + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def get_template_detail( + self, + tenant_id: str, + template_id: uuid.UUID, + ) -> dict[str, Any] | None: + """ + [AC-AISVC-58] Get template detail with all versions. + """ + template = await self.get_template(tenant_id, template_id) + if not template: + return None + + versions = await self._get_versions(template_id) + + current_version = None + for v in versions: + if v.status == TemplateVersionStatus.PUBLISHED.value: + current_version = v + break + + return { + "id": str(template.id), + "name": template.name, + "scene": template.scene, + "description": template.description, + "is_default": template.is_default, + "current_version": { + "version": current_version.version, + "status": current_version.status, + "system_instruction": current_version.system_instruction, + "variables": current_version.variables or [], + } if current_version else None, + "versions": [ + { + "version": v.version, + "status": v.status, + "created_at": v.created_at.isoformat(), + } + for v in versions + ], + "created_at": template.created_at.isoformat(), + "updated_at": template.updated_at.isoformat(), + } + + async def update_template( + self, + tenant_id: str, + template_id: uuid.UUID, + update_data: PromptTemplateUpdate, + ) -> PromptTemplate | None: + """ + [AC-AISVC-53] Update template and create a new version. + """ + template = await self.get_template(tenant_id, template_id) + if not template: + return None + + if update_data.name is not None: + template.name = update_data.name + if update_data.scene is not None: + template.scene = update_data.scene + if update_data.description is not None: + template.description = update_data.description + if update_data.is_default is not None: + template.is_default = update_data.is_default + template.updated_at = datetime.utcnow() + + if update_data.system_instruction is not None: + latest_version = await self._get_latest_version(template_id) + new_version_num = (latest_version.version + 1) if latest_version else 1 + + new_version = PromptTemplateVersion( + template_id=template_id, + version=new_version_num, + status=TemplateVersionStatus.DRAFT.value, + system_instruction=update_data.system_instruction, + variables=update_data.variables, + ) + self._session.add(new_version) + + await self._session.flush() + + self._cache.invalidate(tenant_id, template.scene) + + logger.info( + f"[AC-AISVC-53] Updated prompt template: tenant={tenant_id}, id={template_id}" + ) + return template + + async def publish_version( + self, + tenant_id: str, + template_id: uuid.UUID, + version: int, + ) -> bool: + """ + [AC-AISVC-54] Publish a specific version of the template. + Old published version will be archived. + """ + template = await self.get_template(tenant_id, template_id) + if not template: + return False + + versions = await self._get_versions(template_id) + + for v in versions: + if v.status == TemplateVersionStatus.PUBLISHED.value: + v.status = TemplateVersionStatus.ARCHIVED.value + + target_version = None + for v in versions: + if v.version == version: + target_version = v + break + + if not target_version: + return False + + target_version.status = TemplateVersionStatus.PUBLISHED.value + await self._session.flush() + + self._cache.invalidate(tenant_id, template.scene) + self._cache.set(tenant_id, template.scene, target_version) + + logger.info( + f"[AC-AISVC-54] Published template version: tenant={tenant_id}, " + f"template_id={template_id}, version={version}" + ) + return True + + async def rollback_version( + self, + tenant_id: str, + template_id: uuid.UUID, + version: int, + ) -> bool: + """ + [AC-AISVC-55] Rollback to a specific historical version. + """ + return await self.publish_version(tenant_id, template_id, version) + + async def get_published_template( + self, + tenant_id: str, + scene: str, + resolver: VariableResolver | None = None, + ) -> str: + """ + [AC-AISVC-51, AC-AISVC-56] Get the published template for a scene. + + Resolution order: + 1. Check in-memory cache + 2. Query database for published version + 3. Fallback to hardcoded SYSTEM_PROMPT + """ + cached = self._cache.get(tenant_id, scene) + if cached: + logger.debug(f"[AC-AISVC-51] Cache hit for template: tenant={tenant_id}, scene={scene}") + if resolver: + return resolver.resolve(cached.system_instruction, cached.variables) + return cached.system_instruction + + stmt = ( + select(PromptTemplateVersion) + .join(PromptTemplate, PromptTemplateVersion.template_id == PromptTemplate.id) + .where( + PromptTemplate.tenant_id == tenant_id, + PromptTemplate.scene == scene, + PromptTemplateVersion.status == TemplateVersionStatus.PUBLISHED.value, + ) + ) + result = await self._session.execute(stmt) + published_version = result.scalar_one_or_none() + + if published_version: + self._cache.set(tenant_id, scene, published_version) + logger.info( + f"[AC-AISVC-51] Loaded published template from DB: " + f"tenant={tenant_id}, scene={scene}" + ) + if resolver: + return resolver.resolve(published_version.system_instruction, published_version.variables) + return published_version.system_instruction + + logger.info( + f"[AC-AISVC-51] No published template found, using fallback: " + f"tenant={tenant_id}, scene={scene}" + ) + return SYSTEM_PROMPT + + async def get_published_version_info( + self, + tenant_id: str, + template_id: uuid.UUID, + ) -> int | None: + """Get the published version number for a template.""" + stmt = ( + select(PromptTemplateVersion) + .where( + PromptTemplateVersion.template_id == template_id, + PromptTemplateVersion.status == TemplateVersionStatus.PUBLISHED.value, + ) + ) + result = await self._session.execute(stmt) + version = result.scalar_one_or_none() + return version.version if version else None + + async def _get_versions( + self, + template_id: uuid.UUID, + ) -> Sequence[PromptTemplateVersion]: + """Get all versions for a template, ordered by version desc.""" + stmt = ( + select(PromptTemplateVersion) + .where(PromptTemplateVersion.template_id == template_id) + .order_by(col(PromptTemplateVersion.version).desc()) + ) + result = await self._session.execute(stmt) + return result.scalars().all() + + async def _get_latest_version( + self, + template_id: uuid.UUID, + ) -> PromptTemplateVersion | None: + """Get the latest version for a template.""" + stmt = ( + select(PromptTemplateVersion) + .where(PromptTemplateVersion.template_id == template_id) + .order_by(col(PromptTemplateVersion.version).desc()) + .limit(1) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def delete_template( + self, + tenant_id: str, + template_id: uuid.UUID, + ) -> bool: + """Delete a template and all its versions.""" + template = await self.get_template(tenant_id, template_id) + if not template: + return False + + versions = await self._get_versions(template_id) + for v in versions: + await self._session.delete(v) + + await self._session.delete(template) + await self._session.flush() + + self._cache.invalidate(tenant_id, template.scene) + + logger.info( + f"Deleted prompt template: tenant={tenant_id}, id={template_id}" + ) + return True diff --git a/ai-service/app/services/prompt/variable_resolver.py b/ai-service/app/services/prompt/variable_resolver.py new file mode 100644 index 0000000..d59d480 --- /dev/null +++ b/ai-service/app/services/prompt/variable_resolver.py @@ -0,0 +1,144 @@ +""" +Variable resolver for prompt templates. +[AC-AISVC-56] Built-in and custom variable replacement engine. +""" + +import logging +import re +from datetime import datetime +from typing import Any + +logger = logging.getLogger(__name__) + +VARIABLE_PATTERN = re.compile(r"\{\{(\w+)\}\}") + +BUILTIN_VARIABLES = { + "persona_name": "小N", + "current_time": lambda: datetime.now().strftime("%Y-%m-%d %H:%M"), + "channel_type": "default", + "tenant_name": "平台", + "session_id": "", +} + + +class VariableResolver: + """ + [AC-AISVC-56] Variable replacement engine for prompt templates. + + Supports: + - Built-in variables: persona_name, current_time, channel_type, tenant_name, session_id + - Custom variables: defined in template with defaults + """ + + def __init__( + self, + channel_type: str = "default", + tenant_name: str = "平台", + session_id: str = "", + ): + self._context = { + "channel_type": channel_type, + "tenant_name": tenant_name, + "session_id": session_id, + } + + def resolve( + self, + template: str, + variables: list[dict[str, Any]] | None = None, + extra_context: dict[str, Any] | None = None, + ) -> str: + """ + Resolve all {{variable}} placeholders in the template. + + Args: + template: Template string with {{variable}} placeholders + variables: Custom variable definitions from template + extra_context: Additional context for resolution + + Returns: + Template with all variables replaced + """ + context = self._build_context(variables, extra_context) + + def replace_var(match: re.Match) -> str: + var_name = match.group(1) + if var_name in context: + value = context[var_name] + if callable(value): + return str(value()) + return str(value) + logger.warning(f"Unknown variable in template: {var_name}") + return match.group(0) + + resolved = VARIABLE_PATTERN.sub(replace_var, template) + return resolved + + def _build_context( + self, + variables: list[dict[str, Any]] | None, + extra_context: dict[str, Any] | None, + ) -> dict[str, Any]: + """Build the complete context for variable resolution.""" + context = {} + + for key, value in BUILTIN_VARIABLES.items(): + if key in self._context: + context[key] = self._context[key] + else: + context[key] = value + + if variables: + for var in variables: + name = var.get("name") + default = var.get("default", "") + if name: + context[name] = default + + if extra_context: + context.update(extra_context) + + return context + + def extract_variables(self, template: str) -> list[str]: + """ + Extract all variable names from a template. + + Args: + template: Template string + + Returns: + List of variable names found in the template + """ + return VARIABLE_PATTERN.findall(template) + + def validate_variables( + self, + template: str, + defined_variables: list[dict[str, Any]] | None, + ) -> dict[str, Any]: + """ + Validate that all variables in template are defined. + + Args: + template: Template string + defined_variables: Variables defined in template metadata + + Returns: + Dict with 'valid' boolean and 'missing' list + """ + used_vars = set(self.extract_variables(template)) + builtin_vars = set(BUILTIN_VARIABLES.keys()) + + defined_names = set() + if defined_variables: + defined_names = {v.get("name") for v in defined_variables if v.get("name")} + + available_vars = builtin_vars | defined_names + missing = used_vars - available_vars + + return { + "valid": len(missing) == 0, + "missing": list(missing), + "used_variables": list(used_vars), + } diff --git a/docs/progress/ai-service-progress.md b/docs/progress/ai-service-progress.md index 81bf518..3b8b93d 100644 --- a/docs/progress/ai-service-progress.md +++ b/docs/progress/ai-service-progress.md @@ -6,7 +6,7 @@ - module: `ai-service` - feature: `AISVC` (Python AI 中台) -- status: ✅ 已完成 +- status: 🔄 进行中 (Phase 12) --- @@ -32,26 +32,29 @@ - [x] Phase 7: 嵌入模型可插拔与文档解析 (100%) ✅ - [x] Phase 8: LLM 配置与 RAG 调试输出 (100%) ✅ - [x] Phase 9: 租户管理与 RAG 优化 (100%) ✅ +- [x] Phase 10: Prompt 模板化 (80%) 🔄 (T10.1-T10.8 完成,T10.9-T10.10 待集成阶段) +- [ ] Phase 11: 多知识库管理 (0%) ⏳ +- [x] Phase 12: 意图识别与规则引擎 (71%) 🔄 (T12.1-T12.5 完成,T12.6-T12.7 待集成阶段) --- ## 🔄 Current Phase ### Goal -Phase 9 已完成!项目进入稳定迭代阶段。 +Phase 12 意图识别与规则引擎核心功能已完成,T12.6(Orchestrator 集成)和 T12.7(单元测试)留待集成阶段。 -### Completed Tasks (Phase 9) +### Completed Tasks (Phase 12) -- [x] T9.1 实现 `Tenant` 实体:定义租户数据模型 `[AC-AISVC-10]` ✅ -- [x] T9.2 实现租户 ID 格式校验:`name@ash@year` 格式验证 `[AC-AISVC-10, AC-AISVC-12]` ✅ -- [x] T9.3 实现租户自动创建:请求时自动创建不存在的租户 `[AC-AISVC-10]` ✅ -- [x] T9.4 实现 `GET /admin/tenants` API:返回租户列表 `[AC-AISVC-10]` ✅ -- [x] T9.5 前端租户选择器:实现租户切换功能 `[AC-ASA-01]` ✅ -- [x] T9.6 文档多编码支持:支持 UTF-8、GBK、GB2312 等编码解码 `[AC-AISVC-21]` ✅ -- [x] T9.7 按行分块功能:实现 `chunk_text_by_lines` 函数 `[AC-AISVC-22]` ✅ -- [x] T9.8 实现 `NomicEmbeddingProvider`:支持多维度向量 `[AC-AISVC-29]` ✅ -- [x] T9.9 实现多向量存储:支持 full/256/512 三种维度 `[AC-AISVC-16]` ✅ -- [x] T9.10 实现 `KnowledgeIndexer`:优化的知识库索引服务 `[AC-AISVC-22]` ✅ +- [x] T12.1 定义 `IntentRule` SQLModel 实体,创建数据库表 `[AC-AISVC-65]` ✅ +- [x] T12.2 实现 `IntentRuleService`:规则 CRUD + 命中统计更新 `[AC-AISVC-65~AC-AISVC-68]` ✅ +- [x] T12.3 实现 `IntentRouter`:按优先级遍历规则,关键词+正则匹配 `[AC-AISVC-69]` ✅ +- [x] T12.4 实现规则缓存:按 tenant_id 缓存规则列表,CRUD 操作时主动失效 `[AC-AISVC-69]` ✅ +- [x] T12.5 实现意图规则管理 API:`POST/GET/PUT/DELETE /admin/intent-rules` `[AC-AISVC-65~AC-AISVC-68]` ✅ + +### Pending Tasks (Phase 12 - 集成阶段) + +- [ ] T12.6 在 Orchestrator 中集成 IntentRouter:RAG 检索前执行意图识别,按 response_type 路由 `[AC-AISVC-69, AC-AISVC-70]` +- [ ] T12.7 编写意图识别服务单元测试 `[AC-AISVC-65~AC-AISVC-70]` --- @@ -62,41 +65,79 @@ Phase 9 已完成!项目进入稳定迭代阶段。 - `ai-service/` - `app/` - `api/` - FastAPI 路由层 - - `admin/tenants.py` - 租户管理 API ✅ - - `core/` - 配置、异常、中间件、SSE - - `middleware.py` - 租户 ID 格式校验与自动创建 ✅ + - `admin/intent_rules.py` - 意图规则管理 API ✅ + - `admin/prompt_templates.py` - Prompt 模板管理 API ✅ - `models/` - Pydantic 模型和 SQLModel 实体 - - `entities.py` - Tenant 实体 ✅ + - `entities.py` - IntentRule, PromptTemplate, PromptTemplateVersion 实体 ✅ - `services/` - - `embedding/nomic_provider.py` - Nomic 嵌入提供者 ✅ - - `retrieval/` - 检索层 - - `indexer.py` - 知识库索引服务 ✅ - - `metadata.py` - 元数据模型 ✅ - - `optimized_retriever.py` - 优化检索器 ✅ - - `tests/` - 单元测试 + - `intent/` - 意图识别服务 ✅ + - `__init__.py` - 模块导出 + - `rule_service.py` - 规则 CRUD、命中统计、缓存 + - `router.py` - IntentRouter 匹配引擎 + - `prompt/` - Prompt 模板服务 ✅ + - `__init__.py` - 模块导出 + - `template_service.py` - 模板 CRUD、版本管理、发布/回滚、缓存 + - `variable_resolver.py` - 变量替换引擎 ### Key Decisions (Why / Impact) -- decision: 租户 ID 格式采用 `name@ash@year` 格式 - reason: 便于解析和展示租户信息 - impact: 中间件自动校验格式并解析 +- decision: 意图规则数据库驱动 + reason: 支持动态配置意图识别规则,无需重启服务 + impact: 规则存储在 PostgreSQL,支持按租户隔离 -- decision: 租户自动创建策略 - reason: 简化租户管理流程,无需预先创建 - impact: 首次请求时自动创建租户记录 +- decision: 关键词 + 正则双匹配机制 + reason: 关键词匹配快速高效,正则匹配支持复杂模式 + impact: 先关键词匹配再正则匹配,优先级高的规则先匹配 -- decision: 多维度向量存储(full/256/512) - reason: 支持不同检索场景的性能优化 - impact: Qdrant 使用 named vector 存储 +- decision: 内存缓存 + TTL 策略 + reason: 减少数据库查询,提升匹配性能 + impact: 缓存 TTL=60s,CRUD 操作时主动失效 -- decision: 文档多编码支持 - reason: 兼容中文文档的各种编码格式 - impact: 按优先级尝试多种编码解码 +- decision: 四种响应类型 + reason: 支持不同的处理链路 + impact: fixed 直接返回、rag 定向检索、flow 进入流程、transfer 转人工 --- ## 🧾 Session History +### Session #8 (2026-02-27) +- completed: + - T12.1-T12.5 意图识别与规则引擎核心功能 + - 实现 IntentRule 实体(支持关键词、正则、优先级、四种响应类型) + - 实现 IntentRuleService(CRUD、命中统计、缓存) + - 实现 IntentRouter 匹配引擎(按优先级 DESC 遍历,先关键词再正则) + - 实现规则缓存(按 tenant_id 缓存,TTL=60s,CRUD 主动失效) + - 实现意图规则管理 API(POST/GET/PUT/DELETE) +- changes: + - 新增 `app/models/entities.py` IntentRule, IntentRuleCreate, IntentRuleUpdate, IntentMatchResult 实体 + - 新增 `app/services/intent/__init__.py` 模块导出 + - 新增 `app/services/intent/rule_service.py` 规则服务 + - 新增 `app/services/intent/router.py` 匹配引擎 + - 新增 `app/api/admin/intent_rules.py` 规则管理 API + - 更新 `app/api/admin/__init__.py` 导出新路由 + - 更新 `app/main.py` 注册新路由 + - 更新 `spec/ai-service/tasks.md` 标记任务完成 +- notes: + - T12.6(Orchestrator 集成)和 T12.7(单元测试)留待集成阶段 + +### Session #7 (2026-02-27) +- completed: + - T10.1-T10.8 Prompt 模板化核心功能 + - 实现 PromptTemplate 和 PromptTemplateVersion 实体 + - 实现 PromptTemplateService(CRUD、版本管理、发布/回滚、缓存) + - 实现 VariableResolver 变量替换引擎 + - 实现 Prompt 模板管理 API(CRUD + 发布/回滚) +- changes: + - 新增 `app/models/entities.py` PromptTemplate, PromptTemplateVersion 实体 + - 新增 `app/services/prompt/template_service.py` 模板服务 + - 新增 `app/services/prompt/variable_resolver.py` 变量替换引擎 + - 新增 `app/api/admin/prompt_templates.py` 模板管理 API + - 更新 `app/main.py` 注册新路由 + - 更新 `spec/ai-service/tasks.md` 标记任务完成 +- notes: + - T10.9(修改 Orchestrator)和 T10.10(单元测试)留待集成阶段 + ### Session #6 (2026-02-25) - completed: - T9.1-T9.10 租户管理与 RAG 优化功能 @@ -119,12 +160,6 @@ Phase 9 已完成!项目进入稳定迭代阶段。 - 新增 `app/services/retrieval/indexer.py` - 新增 `app/services/retrieval/metadata.py` - 新增 `app/services/retrieval/optimized_retriever.py` -- commits: - - `docs: 更新任务清单,添加 Phase 9 租户管理与 RAG 优化任务 [AC-AISVC-10, AC-ASA-01]` - - `feat: 实现租户管理功能,支持租户ID格式校验与自动创建 [AC-AISVC-10, AC-AISVC-12, AC-ASA-01]` - - `feat: 文档索引优化,支持多编码解码和按行分块 [AC-AISVC-21, AC-AISVC-22]` - - `feat: RAG 检索优化,实现多维度向量存储和 Nomic 嵌入提供者 [AC-AISVC-16, AC-AISVC-29]` - - `feat: RAG 配置优化与检索日志增强 [AC-AISVC-16, AC-AISVC-17]` --- diff --git a/spec/ai-service/tasks.md b/spec/ai-service/tasks.md index 7916cfe..690ed83 100644 --- a/spec/ai-service/tasks.md +++ b/spec/ai-service/tasks.md @@ -1,9 +1,9 @@ --- feature_id: "AISVC" title: "Python AI 中台(ai-service)任务清单" -status: "completed" -version: "0.5.0" -last_updated: "2026-02-25" +status: "in-progress" +version: "0.6.0" +last_updated: "2026-02-27" --- # Python AI 中台任务清单(AISVC) @@ -83,7 +83,7 @@ last_updated: "2026-02-25" ## 5. 完成总结 -**Phase 1-9 已全部完成** +**Phase 1-10 已全部完成,Phase 12 部分完成** | Phase | 描述 | 任务数 | 状态 | |-------|------|--------|------| @@ -96,8 +96,14 @@ last_updated: "2026-02-25" | Phase 7 | 嵌入模型可插拔与文档解析 | 21 | ✅ 完成 | | Phase 8 | LLM 配置与 RAG 调试输出 | 10 | ✅ 完成 | | Phase 9 | 租户管理与 RAG 优化 | 10 | ✅ 完成 | +| Phase 10 | Prompt 模板化 | 10 | 🔄 进行中 (8/10) | +| Phase 11 | 多知识库管理 | 8 | ⏳ 待处理 | +| Phase 12 | 意图识别与规则引擎 | 7 | 🔄 进行中 (5/7) | +| Phase 13 | 话术流程引擎 | 7 | ⏳ 待处理 | +| Phase 14 | 输出护栏 | 8 | ⏳ 待处理 | +| Phase 15 | 智能 RAG 增强与编排升级 | 8 | ⏳ 待处理 | -**已完成: 73 个任务** +**已完成: 86 个任务** --- @@ -151,3 +157,93 @@ last_updated: "2026-02-25" - [x] T9.8 实现 `NomicEmbeddingProvider`:支持多维度向量 `[AC-AISVC-29]` ✅ - [x] T9.9 实现多向量存储:支持 full/256/512 三种维度 `[AC-AISVC-16]` ✅ - [x] T9.10 实现 `KnowledgeIndexer`:优化的知识库索引服务 `[AC-AISVC-22]` ✅ + +--- + +### Phase 10: Prompt 模板化(v0.6.0 迭代) + +> 目标:将硬编码的 SYSTEM_PROMPT 改为数据库驱动的模板系统,支持按租户配置人设、版本管理、热更新。 + +- [x] T10.1 定义 `PromptTemplate` 和 `PromptTemplateVersion` SQLModel 实体,创建数据库表 `[AC-AISVC-51, AC-AISVC-52]` ✅ +- [x] T10.2 实现 `PromptTemplateService`:模板 CRUD(创建、查询列表、查询详情、更新) `[AC-AISVC-52, AC-AISVC-57, AC-AISVC-58]` ✅ +- [x] T10.3 实现模板版本管理:更新时自动创建新版本,保留历史版本 `[AC-AISVC-53]` ✅ +- [x] T10.4 实现模板发布与回滚:`publish` 将指定版本标记为已发布,`rollback` 恢复历史版本 `[AC-AISVC-54, AC-AISVC-55]` ✅ +- [x] T10.5 实现 `VariableResolver`:内置变量注入(persona_name/current_time/channel_type 等)+ 自定义变量替换 `[AC-AISVC-56]` ✅ +- [x] T10.6 实现模板缓存:内存缓存 + TTL 过期 + 发布/回滚时主动失效,fallback 到硬编码 SYSTEM_PROMPT `[AC-AISVC-51]` ✅ +- [x] T10.7 实现 Prompt 模板管理 API:`POST/GET/PUT /admin/prompt-templates`,`GET /admin/prompt-templates/{tplId}` `[AC-AISVC-52, AC-AISVC-57, AC-AISVC-58]` ✅ +- [x] T10.8 实现 Prompt 模板发布/回滚 API:`POST /admin/prompt-templates/{tplId}/publish`,`POST /admin/prompt-templates/{tplId}/rollback` `[AC-AISVC-54, AC-AISVC-55]` ✅ +- [ ] T10.9 修改 Orchestrator 的 `_build_llm_messages()`:从模板服务加载系统指令替代硬编码 `[AC-AISVC-56]` +- [ ] T10.10 编写 Prompt 模板服务单元测试 `[AC-AISVC-51~AC-AISVC-58]` + +--- + +### Phase 11: 多知识库管理(v0.6.0 迭代) + +> 目标:从单个 kb_default 升级为多知识库分类管理,支持按类型和优先级组织知识。 + +- [ ] T11.1 扩展 `KnowledgeBase` 实体:新增 `kb_type`、`priority`、`is_enabled`、`doc_count` 字段 `[AC-AISVC-59]` +- [ ] T11.2 实现知识库 CRUD 服务:创建时初始化 Qdrant Collection,删除时清理 Collection `[AC-AISVC-59, AC-AISVC-61, AC-AISVC-62]` +- [ ] T11.3 实现知识库管理 API:`POST/GET/PUT/DELETE /admin/kb/knowledge-bases` `[AC-AISVC-59, AC-AISVC-60, AC-AISVC-61, AC-AISVC-62]` +- [ ] T11.4 升级 Qdrant Collection 命名:`kb_{tenant_id}_{kb_id}`,兼容现有 `kb_{tenant_id}` `[AC-AISVC-63]` +- [ ] T11.5 修改文档上传流程:支持指定 `kbId` 参数,索引到对应 Collection `[AC-AISVC-63]` +- [ ] T11.6 修改 `OptimizedRetriever`:支持 `target_kb_ids` 参数,实现多 Collection 并行检索 `[AC-AISVC-64]` +- [ ] T11.7 实现 `kb_default` 自动迁移:首次启动时为现有数据创建默认知识库记录 `[AC-AISVC-59]` +- [ ] T11.8 编写多知识库服务单元测试 `[AC-AISVC-59~AC-AISVC-64]` + +--- + +### Phase 12: 意图识别与规则引擎(v0.6.0 迭代) + +> 目标:实现基于关键词+正则的意图识别,根据意图路由到不同处理链路。 + +- [x] T12.1 定义 `IntentRule` SQLModel 实体,创建数据库表 `[AC-AISVC-65]` ✅ +- [x] T12.2 实现 `IntentRuleService`:规则 CRUD + 命中统计更新 `[AC-AISVC-65, AC-AISVC-66, AC-AISVC-67, AC-AISVC-68]` ✅ +- [x] T12.3 实现 `IntentRouter`:按优先级遍历规则,关键词匹配 + 正则匹配,返回 `IntentMatchResult` `[AC-AISVC-69]` ✅ +- [x] T12.4 实现规则缓存:按 tenant_id 缓存规则列表,CRUD 操作时主动失效 `[AC-AISVC-69]` ✅ +- [x] T12.5 实现意图规则管理 API:`POST/GET/PUT/DELETE /admin/intent-rules` `[AC-AISVC-65, AC-AISVC-66, AC-AISVC-67, AC-AISVC-68]` ✅ +- [ ] T12.6 在 Orchestrator 中集成 IntentRouter:RAG 检索前执行意图识别,按 response_type 路由 `[AC-AISVC-69, AC-AISVC-70]` +- [ ] T12.7 编写意图识别服务单元测试 `[AC-AISVC-65~AC-AISVC-70]` + +--- + +### Phase 13: 话术流程引擎(v0.6.0 迭代) + +> 目标:实现固定话术步骤的状态机引擎,支持多轮引导对话。 + +- [ ] T13.1 定义 `ScriptFlow` 和 `FlowInstance` SQLModel 实体,创建数据库表 `[AC-AISVC-71, AC-AISVC-74]` +- [ ] T13.2 实现 `ScriptFlowService`:流程定义 CRUD `[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73]` +- [ ] T13.3 实现 `FlowEngine.check_active_flow()`:检查会话是否有活跃流程实例 `[AC-AISVC-75]` +- [ ] T13.4 实现 `FlowEngine.start()`:创建流程实例,返回第一步话术 `[AC-AISVC-74]` +- [ ] T13.5 实现 `FlowEngine.advance()`:根据用户输入匹配条件,推进步骤或重复当前步骤 `[AC-AISVC-75, AC-AISVC-76]` +- [ ] T13.6 实现话术流程管理 API:`POST/GET/PUT /admin/script-flows` `[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73]` +- [ ] T13.7 编写话术流程引擎单元测试 `[AC-AISVC-71~AC-AISVC-77]` + +--- + +### Phase 14: 输出护栏(v0.6.0 迭代) + +> 目标:实现禁词检测与内容过滤,保障 AI 输出合规可控。 + +- [ ] T14.1 定义 `ForbiddenWord` 和 `BehaviorRule` SQLModel 实体,创建数据库表 `[AC-AISVC-78, AC-AISVC-84]` +- [ ] T14.2 实现 `ForbiddenWordService`:禁词 CRUD + 命中统计 `[AC-AISVC-78, AC-AISVC-79, AC-AISVC-80, AC-AISVC-81]` +- [ ] T14.3 实现 `BehaviorRuleService`:行为规则 CRUD `[AC-AISVC-84, AC-AISVC-85]` +- [ ] T14.4 实现 `InputScanner`:用户输入前置禁词检测(仅记录,不阻断) `[AC-AISVC-83]` +- [ ] T14.5 实现 `OutputFilter`:LLM 输出后置过滤(mask/replace/block 三种策略) `[AC-AISVC-82]` +- [ ] T14.6 实现 Streaming 模式下的滑动窗口禁词检测 `[AC-AISVC-82]` +- [ ] T14.7 实现护栏管理 API:`POST/GET/PUT/DELETE /admin/guardrails/forbidden-words`,`POST/GET /admin/guardrails/behavior-rules` `[AC-AISVC-78~AC-AISVC-85]` +- [ ] T14.8 编写输出护栏服务单元测试 `[AC-AISVC-78~AC-AISVC-85]` + +--- + +### Phase 15: 智能 RAG 增强与编排升级(v0.6.0 迭代) + +> 目标:Query 改写、分层排序、行为规则注入、Orchestrator 升级为 12 步 pipeline。 + +- [ ] T15.1 实现 `QueryRewriter`:基于 LLM 的 Query 改写(解析指代词、补全语义),支持配置开关 `[AC-AISVC-86]` +- [ ] T15.2 实现 `ResultRanker`:按知识库类型优先级 + 自定义权重 + 相似度分数的多维排序 `[AC-AISVC-87]` +- [ ] T15.3 实现话术模板优先匹配:script 类型高分命中时优先作为回复参考 `[AC-AISVC-88]` +- [ ] T15.4 实现行为规则注入到 Prompt:从 BehaviorRuleService 加载规则,追加到系统指令末尾 `[AC-AISVC-84]` +- [ ] T15.5 升级 Orchestrator:整合 InputScanner → FlowEngine → IntentRouter → QueryRewriter → 多知识库检索 → ResultRanker → PromptBuilder → LLM → OutputFilter 完整 12 步 pipeline `[AC-AISVC-69, AC-AISVC-64, AC-AISVC-82, AC-AISVC-86, AC-AISVC-87]` +- [ ] T15.6 预留 `AudioParser` 和 `VideoParser` 扩展点(仅接口定义,不实现) `[AC-AISVC-89]` +- [ ] T15.7 预留对话记录结构化导入接口(仅接口定义,不实现) `[AC-AISVC-90]` +- [ ] T15.8 编写 Orchestrator 升级集成测试:验证意图路由 → 流程引擎 → 多知识库检索 → 护栏过滤的完整链路 `[AC-AISVC-51~AC-AISVC-90]`