chore(master): 清理V2合并后的旧版遗留文件
This commit is contained in:
18
.env.example
18
.env.example
@@ -1,18 +0,0 @@
|
||||
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
|
||||
DJANGO_DEBUG=true
|
||||
DJANGO_ALLOWED_HOSTS=*
|
||||
|
||||
# OpenAI-compatible LLM API
|
||||
LLM_API_KEY=your_llm_api_key
|
||||
LLM_BASE_URL=https://api.openai.com/v1
|
||||
LLM_MODEL=gpt-4.1-mini
|
||||
|
||||
# Embedding model for RAG
|
||||
# Leave EMBEDDING_API_KEY empty to reuse LLM_API_KEY if desired.
|
||||
EMBEDDING_API_KEY=
|
||||
EMBEDDING_BASE_URL=
|
||||
EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
SCENARIO_CONFIG_DIR=configs
|
||||
UPLOAD_ROOT=data/uploads
|
||||
CHROMA_PATH=data/chroma
|
||||
172
AGENTS.md
172
AGENTS.md
@@ -1,172 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文档约定本项目后续由人或编码 Agent 协作开发时需要遵守的边界、风格和实现顺序。
|
||||
|
||||
## 项目定位
|
||||
|
||||
Universal Agent Demo Framework 是一个用于复试展示的通用 AI Agent Demo 框架。
|
||||
|
||||
优先目标:
|
||||
|
||||
- 快速适配未知复试题。
|
||||
- 保证本地可运行。
|
||||
- 保证代码结构清楚,方便讲解。
|
||||
- 避免为了平台完整性牺牲改题速度。
|
||||
|
||||
## 架构原则
|
||||
|
||||
采用:
|
||||
|
||||
```text
|
||||
Django 单体 + 独立 Agent Core + Docker Compose
|
||||
```
|
||||
|
||||
核心边界:
|
||||
|
||||
- Django 负责页面、数据库、文件上传、审计日志和后台管理。
|
||||
- Agent Core 负责 RAG、Prompt、工具调用、模型适配和结构化输出。
|
||||
- Django View 不直接写大模型调用、向量检索和工具执行细节。
|
||||
- Agent Core 不依赖 Django View。
|
||||
|
||||
## 模块边界
|
||||
|
||||
### config
|
||||
|
||||
负责 Django 项目配置、URL 总入口、环境变量、静态资源、上传路径和部署配置。
|
||||
|
||||
### apps.scenarios
|
||||
|
||||
负责场景列表、场景配置读取、场景元信息展示。
|
||||
|
||||
### apps.documents
|
||||
|
||||
负责文件上传、文件记录、文件状态和触发 RAG 入库。
|
||||
|
||||
### apps.chat
|
||||
|
||||
负责对话页面、用户输入表单、调用 Agent Core 和展示结果。
|
||||
|
||||
### apps.audit
|
||||
|
||||
负责审计日志模型、日志写入服务、日志列表和详情页。
|
||||
|
||||
### agent_core
|
||||
|
||||
负责 Agent 编排、RAG、工具注册、LLM Provider、结构化输出和 Adapter 扩展。
|
||||
|
||||
## 开发顺序
|
||||
|
||||
建议按以下顺序推进:
|
||||
|
||||
1. 创建 Django 项目骨架。
|
||||
2. 完成 Config 模块。
|
||||
3. 完成 Scenarios 模块,先展示 5 个场景。
|
||||
4. 完成 Agent Core 最小闭环,先返回模拟结果。
|
||||
5. 完成 Chat 页面,打通对话链路。
|
||||
6. 完成 Audit 模块,记录每次对话。
|
||||
7. 完成 Documents 模块,支持上传文件。
|
||||
8. 完成 RAG 入库和检索。
|
||||
9. 完成内置工具系统。
|
||||
10. 补 Docker Compose 一键启动。
|
||||
|
||||
当前仓库状态说明:
|
||||
|
||||
- Django 单体骨架已完成。
|
||||
- 5 个预置场景 YAML 已接通首页和对话页。
|
||||
- Agent Core 已具备 Prompt 编排、结构化解析、工具注册和 RAG fallback / Chroma 双路径。
|
||||
- Chat、Documents、Audit 页面已经可以形成完整演示闭环。
|
||||
- 全量测试已覆盖主要模块行为,并默认隔离真实 LLM 网络调用。
|
||||
|
||||
## 编码约定
|
||||
|
||||
- Python 代码优先保持简单、直观、可讲解。
|
||||
- 不为了抽象而抽象。
|
||||
- View 只做请求处理和页面渲染,复杂逻辑放到 `services.py` 或 `agent_core`。
|
||||
- 配置化优先,业务场景不要写死在代码中。
|
||||
- 工具函数必须通过 Tool Registry 注册。
|
||||
- 模型调用必须通过 LLM Provider,不允许散落在业务代码中。
|
||||
- 审计日志要记录成功和失败两种情况。
|
||||
- 不在日志中保存 API Key、密钥或敏感环境变量。
|
||||
- 新增或重构模块时,优先补清晰的中文注释,说明职责边界、输入输出和设计取舍。
|
||||
- 页面模板优先直接表达业务信息,不在模板中堆积复杂逻辑判断。
|
||||
- 测试优先覆盖服务层和核心编排逻辑,再由页面测试补齐关键展示行为。
|
||||
|
||||
## 文档约定
|
||||
|
||||
需求文档放在:
|
||||
|
||||
```text
|
||||
docs/
|
||||
```
|
||||
|
||||
需求分析文档放在:
|
||||
|
||||
```text
|
||||
docs/需求分析/
|
||||
```
|
||||
|
||||
设计文档放在:
|
||||
|
||||
```text
|
||||
docs/设计文档/
|
||||
```
|
||||
|
||||
场景配置放在:
|
||||
|
||||
```text
|
||||
configs/
|
||||
```
|
||||
|
||||
重要设计变更需要同步更新:
|
||||
|
||||
- `README.md`
|
||||
- `docs/需求分析/1.V1总需求文档.md`
|
||||
- 相关模块需求文档
|
||||
- `AGENTS.md` 中的协作边界与当前实现状态
|
||||
|
||||
推荐同步文档的场景:
|
||||
|
||||
- 新增用户可见页面或流程。
|
||||
- 调整环境变量、生效方式或部署命令。
|
||||
- 修改 Agent Core 的输入输出合约。
|
||||
- 新增工具、审计字段或场景配置字段。
|
||||
|
||||
## 测试与验证约定
|
||||
|
||||
每个阶段至少验证:
|
||||
|
||||
- Django 可以启动。
|
||||
- 首页可以访问。
|
||||
- 场景列表可显示。
|
||||
- 对话流程可执行。
|
||||
- 出错时页面有清晰提示。
|
||||
- 审计日志能记录。
|
||||
- Docker Compose 可以启动。
|
||||
|
||||
当前默认验证命令:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
python manage.py check
|
||||
docker compose config
|
||||
```
|
||||
|
||||
补充约定:
|
||||
|
||||
- 若本地 `.env` 存在真实模型密钥,测试仍应保持可离线执行。
|
||||
- 每完成一项功能或一轮重构后,应先跑相关测试,再跑全量测试或核心回归测试。
|
||||
- 完成改动后,按逻辑分组使用 Conventional Commit 风格提交到本地。
|
||||
|
||||
## 不优先做的事项
|
||||
|
||||
第一版不要优先做:
|
||||
|
||||
- React / Vue 前端。
|
||||
- 多租户。
|
||||
- 复杂 RBAC。
|
||||
- 完整工作流引擎。
|
||||
- 深度 Dify 集成。
|
||||
- 微服务拆分。
|
||||
- 分布式任务队列。
|
||||
|
||||
这些内容可以作为后续增强,不应影响 V1 快速成型。
|
||||
15
Dockerfile
15
Dockerfile
@@ -1,15 +0,0 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . /app/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"]
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
class LLMConfigurationError(ValueError):
|
||||
"""LLM 调用缺少关键配置时抛出的业务异常。"""
|
||||
|
||||
|
||||
class EmbeddingConfigurationError(ValueError):
|
||||
"""Embedding 调用缺少关键配置时抛出的业务异常。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""
|
||||
统一的模型响应对象。
|
||||
|
||||
Agent Core 的 Orchestrator 只依赖这一个结构,而不直接感知底层供应商差异。
|
||||
"""
|
||||
content: str = ""
|
||||
model_name: str = ""
|
||||
success: bool = True
|
||||
error: Exception | None = None
|
||||
|
||||
|
||||
class MockLLMProvider:
|
||||
"""
|
||||
本地和测试默认使用的 Mock Provider。
|
||||
|
||||
设计目标不是拟真对话,而是提供一个稳定、可断言、可结构化解析的响应,
|
||||
让前后端在未接入真实模型时也能完整演示链路。
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str = "mock-model"):
|
||||
self.model_name = model_name or "mock-model"
|
||||
|
||||
def generate(self, messages: list[dict], response_format: dict | None = None) -> LLMResponse:
|
||||
user_content = _find_last_user_message(messages)
|
||||
return LLMResponse(
|
||||
content=json.dumps(
|
||||
{
|
||||
"answer": f"模拟回答:{user_content}",
|
||||
"confidence": "medium",
|
||||
"references": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
model_name=self.model_name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
class OpenAICompatibleProvider:
|
||||
"""调用 OpenAI Chat Completions 兼容接口的 Provider。"""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.model_name = model_name
|
||||
|
||||
def generate(self, messages: list[dict], response_format: dict | None = None) -> LLMResponse:
|
||||
if not self.api_key:
|
||||
return LLMResponse(
|
||||
model_name=self.model_name,
|
||||
success=False,
|
||||
error=LLMConfigurationError("LLM_API_KEY 未配置,无法调用 OpenAI 兼容模型接口"),
|
||||
)
|
||||
payload = {
|
||||
"model": self.model_name,
|
||||
"messages": messages,
|
||||
}
|
||||
if response_format:
|
||||
payload["response_format"] = response_format
|
||||
try:
|
||||
data = _post_json(
|
||||
base_url=self.base_url,
|
||||
endpoint="chat/completions",
|
||||
api_key=self.api_key,
|
||||
payload=payload,
|
||||
)
|
||||
choice = data.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content", "")
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model_name=data.get("model", self.model_name),
|
||||
success=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return LLMResponse(model_name=self.model_name, success=False, error=exc)
|
||||
|
||||
|
||||
class OpenAICompatibleEmbeddingProvider:
|
||||
"""调用 OpenAI Embeddings 兼容接口的 Provider。"""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.model_name = model_name
|
||||
|
||||
def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
||||
if not self.api_key:
|
||||
raise EmbeddingConfigurationError("EMBEDDING_API_KEY 未配置,无法调用 OpenAI 兼容 Embedding 接口")
|
||||
data = _post_json(
|
||||
base_url=self.base_url,
|
||||
endpoint="embeddings",
|
||||
api_key=self.api_key,
|
||||
payload={"model": self.model_name, "input": texts},
|
||||
)
|
||||
return [item.get("embedding", []) for item in data.get("data", [])]
|
||||
|
||||
|
||||
def create_llm_provider(config: dict | None = None):
|
||||
"""
|
||||
根据配置创建 LLM Provider。
|
||||
|
||||
默认策略:
|
||||
- 明确指定 `LLM_PROVIDER=mock` 时使用 Mock
|
||||
- 未指定但存在 `LLM_API_KEY` 时默认走 OpenAI 兼容接口
|
||||
- 否则回退到 Mock,保证页面仍可闭环
|
||||
"""
|
||||
config = config or {}
|
||||
provider_name = _resolve_provider_name(config)
|
||||
model_name = config.get("LLM_MODEL", "mock-model")
|
||||
if provider_name == "mock":
|
||||
return MockLLMProvider(model_name=model_name)
|
||||
return OpenAICompatibleProvider(
|
||||
api_key=config.get("LLM_API_KEY", ""),
|
||||
base_url=config.get("LLM_BASE_URL", "https://api.openai.com/v1"),
|
||||
model_name=model_name,
|
||||
)
|
||||
|
||||
|
||||
def create_embedding_provider(config: dict | None = None):
|
||||
"""
|
||||
创建 Embedding Provider。
|
||||
|
||||
当未单独配置 Embedding Key 或 Base URL 时,会自动复用 LLM 配置,
|
||||
以减少复试演示时的环境变量负担。
|
||||
"""
|
||||
config = config or {}
|
||||
return OpenAICompatibleEmbeddingProvider(
|
||||
api_key=config.get("EMBEDDING_API_KEY", config.get("LLM_API_KEY", "")),
|
||||
base_url=config.get("EMBEDDING_BASE_URL", config.get("LLM_BASE_URL", "https://api.openai.com/v1")),
|
||||
model_name=config.get("EMBEDDING_MODEL", "text-embedding-3-small"),
|
||||
)
|
||||
|
||||
|
||||
def get_runtime_llm_config(overrides: dict | None = None) -> dict:
|
||||
"""
|
||||
从环境变量读取运行时配置。
|
||||
|
||||
Agent Core 通过这一层读取模型配置,避免直接依赖 Django settings,
|
||||
这样本模块在独立脚本、测试和 Django 环境中都可复用。
|
||||
"""
|
||||
config = {
|
||||
"LLM_PROVIDER": os.environ.get("LLM_PROVIDER", ""),
|
||||
"LLM_API_KEY": os.environ.get("LLM_API_KEY", ""),
|
||||
"LLM_BASE_URL": os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1"),
|
||||
"LLM_MODEL": os.environ.get("LLM_MODEL", "mock-model"),
|
||||
}
|
||||
if overrides:
|
||||
config.update(overrides)
|
||||
return config
|
||||
|
||||
|
||||
def _resolve_provider_name(config: dict) -> str:
|
||||
"""统一推导当前应启用的 Provider 名称。"""
|
||||
provider_name = config.get("LLM_PROVIDER")
|
||||
if provider_name:
|
||||
return provider_name
|
||||
return "openai_compatible" if config.get("LLM_API_KEY") else "mock"
|
||||
|
||||
|
||||
def _find_last_user_message(messages: list[dict]) -> str:
|
||||
"""从消息列表中提取最后一条用户输入,用于 Mock Provider 回显。"""
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "user":
|
||||
return message.get("content", "")
|
||||
return ""
|
||||
|
||||
|
||||
def _post_json(base_url: str, endpoint: str, api_key: str, payload: dict) -> dict:
|
||||
"""向 OpenAI 兼容接口发送 JSON POST 请求并解析响应。"""
|
||||
url = f"{base_url.rstrip('/')}/{endpoint}"
|
||||
request = Request(
|
||||
url,
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=60) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except URLError as exc:
|
||||
raise RuntimeError(f"OpenAI 兼容接口调用失败:{exc}") from exc
|
||||
@@ -1,153 +0,0 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from .llm_provider import create_llm_provider, get_runtime_llm_config
|
||||
from .results import AgentResult
|
||||
from .structured_output import (
|
||||
build_response_schema_hint,
|
||||
extract_answer_from_structured_output,
|
||||
parse_structured_output,
|
||||
)
|
||||
from .tool_registry import run_declared_tools
|
||||
from .rag.retriever import retrieve
|
||||
|
||||
|
||||
def run_agent(scenario_config: dict, user_input: str, options: dict | None = None) -> AgentResult:
|
||||
"""
|
||||
执行当前场景的最小 Agent 闭环。
|
||||
|
||||
处理顺序保持和设计文档一致:
|
||||
1. 读取场景配置
|
||||
2. 执行 RAG 检索
|
||||
3. 执行声明式工具
|
||||
4. 构造 Prompt 并调用 LLM
|
||||
5. 解析结构化结果
|
||||
6. 统一返回 AgentResult
|
||||
"""
|
||||
started_at = time.perf_counter()
|
||||
options = options or {}
|
||||
output_type = scenario_config.get("output", {}).get("type", "general_answer")
|
||||
|
||||
references = _collect_references(scenario_config=scenario_config, user_input=user_input, options=options)
|
||||
tool_calls = run_declared_tools(scenario_config.get("tools", []), user_input)
|
||||
messages = build_messages(
|
||||
scenario_config=scenario_config,
|
||||
user_input=user_input,
|
||||
references=references,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
|
||||
provider = options.get("llm_provider") or create_llm_provider(
|
||||
get_runtime_llm_config(options.get("llm_config"))
|
||||
)
|
||||
llm_response = provider.generate(
|
||||
messages,
|
||||
response_format=build_response_schema_hint(output_type),
|
||||
)
|
||||
latency_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
|
||||
if not llm_response.success:
|
||||
return AgentResult(
|
||||
answer="模型调用失败,请检查配置或稍后重试。",
|
||||
structured_output={},
|
||||
references=references,
|
||||
tool_calls=tool_calls,
|
||||
raw_output="",
|
||||
model_name=llm_response.model_name or "unknown-model",
|
||||
latency_ms=latency_ms,
|
||||
status="failed",
|
||||
error=str(llm_response.error or "未知模型错误"),
|
||||
)
|
||||
|
||||
structured_output, _ = parse_structured_output(llm_response.content, output_type)
|
||||
answer = extract_answer_from_structured_output(structured_output, llm_response.content)
|
||||
return AgentResult(
|
||||
answer=answer,
|
||||
structured_output=structured_output,
|
||||
references=references,
|
||||
tool_calls=tool_calls,
|
||||
raw_output=llm_response.content,
|
||||
model_name=llm_response.model_name or "unknown-model",
|
||||
latency_ms=latency_ms,
|
||||
status="success",
|
||||
)
|
||||
|
||||
|
||||
def build_messages(
|
||||
scenario_config: dict,
|
||||
user_input: str,
|
||||
references: list[dict],
|
||||
tool_calls: list[dict],
|
||||
) -> list[dict]:
|
||||
"""将场景配置、检索结果和工具结果整合为最小可解释 Prompt。"""
|
||||
agent_config = scenario_config.get("agent", {})
|
||||
system_message = "\n".join(
|
||||
[
|
||||
f"你当前扮演的角色:{agent_config.get('role', '通用业务助手')}",
|
||||
f"当前任务目标:{agent_config.get('goal', '根据输入生成结构化结果')}",
|
||||
"执行要求:",
|
||||
_format_instructions(agent_config.get("instructions", [])),
|
||||
f"输出类型:{scenario_config.get('output', {}).get('type', 'general_answer')}",
|
||||
"请优先输出 JSON 对象,字段必须贴近约定输出结构。",
|
||||
]
|
||||
)
|
||||
context_message = "\n".join(
|
||||
[
|
||||
f"当前场景:{scenario_config.get('name', '未命名场景')}",
|
||||
_format_references(references),
|
||||
_format_tool_calls(tool_calls),
|
||||
]
|
||||
)
|
||||
return [
|
||||
{"role": "system", "content": system_message},
|
||||
{"role": "assistant", "content": context_message},
|
||||
{"role": "user", "content": user_input},
|
||||
]
|
||||
|
||||
|
||||
def _collect_references(scenario_config: dict, user_input: str, options: dict) -> list[dict]:
|
||||
"""按场景配置执行检索,并保持无 RAG 场景也能正常返回空列表。"""
|
||||
rag_config = scenario_config.get("rag", {})
|
||||
if not rag_config.get("enabled"):
|
||||
return []
|
||||
return retrieve(
|
||||
scenario_id=scenario_config.get("id", ""),
|
||||
query=user_input,
|
||||
collection=rag_config.get("collection", scenario_config.get("id", "")),
|
||||
top_k=rag_config.get("top_k", 5),
|
||||
document_ids=options.get("document_ids"),
|
||||
store_path=options.get("rag_store_path"),
|
||||
)
|
||||
|
||||
|
||||
def _format_instructions(instructions: list[str]) -> str:
|
||||
if not instructions:
|
||||
return "1. 结合知识库和工具结果回答。\n2. 信息不足时明确说明。"
|
||||
return "\n".join(f"{index}. {item}" for index, item in enumerate(instructions, start=1))
|
||||
|
||||
|
||||
def _format_references(references: list[dict]) -> str:
|
||||
if not references:
|
||||
return "知识库引用:当前没有检索到可用片段。"
|
||||
lines = ["知识库引用:"]
|
||||
for index, reference in enumerate(references, start=1):
|
||||
lines.append(
|
||||
f"{index}. 来源={reference.get('source', '未知来源')} 内容={reference.get('content', '')}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_tool_calls(tool_calls: list[dict]) -> str:
|
||||
if not tool_calls:
|
||||
return "工具结果:当前场景未声明工具或无需调用工具。"
|
||||
lines = ["工具结果:"]
|
||||
for index, tool_call in enumerate(tool_calls, start=1):
|
||||
if tool_call.get("success"):
|
||||
lines.append(
|
||||
f"{index}. 工具={tool_call.get('tool_name')} 结果={json.dumps(tool_call.get('result', {}), ensure_ascii=False)}"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f"{index}. 工具={tool_call.get('tool_name')} 失败={tool_call.get('error', '未知错误')}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from agent_core.llm_provider import create_embedding_provider
|
||||
|
||||
|
||||
def _client(path: str | Path | None = None):
|
||||
"""按给定路径初始化 Chroma 持久化客户端。"""
|
||||
import chromadb
|
||||
|
||||
resolved_path = str(path or settings.CHROMA_PATH)
|
||||
return chromadb.PersistentClient(path=resolved_path)
|
||||
|
||||
|
||||
def _embedding_provider():
|
||||
"""从 Django settings 构造 Embedding Provider,避免在业务层散落配置读取。"""
|
||||
return create_embedding_provider(
|
||||
{
|
||||
"EMBEDDING_API_KEY": settings.EMBEDDING_API_KEY,
|
||||
"EMBEDDING_BASE_URL": settings.EMBEDDING_BASE_URL,
|
||||
"EMBEDDING_MODEL": settings.EMBEDDING_MODEL,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def upsert_chunks(
|
||||
collection: str,
|
||||
chunks: list[dict],
|
||||
store_path: str | Path | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
将 chunk 写入 Chroma。
|
||||
|
||||
同一 document_id 重新入库前会先删除旧记录,保证一次文档只有一份有效向量数据。
|
||||
"""
|
||||
client = _client(store_path)
|
||||
chroma_collection = client.get_or_create_collection(collection)
|
||||
document_ids = {chunk["document_id"] for chunk in chunks if chunk.get("document_id") is not None}
|
||||
for document_id in document_ids:
|
||||
chroma_collection.delete(where={"document_id": document_id})
|
||||
texts = [chunk["content"] for chunk in chunks]
|
||||
embeddings = _embedding_provider().embed_texts(texts)
|
||||
chroma_collection.upsert(
|
||||
ids=[chunk["chunk_id"] for chunk in chunks],
|
||||
documents=texts,
|
||||
embeddings=embeddings,
|
||||
metadatas=[
|
||||
{
|
||||
"scenario_id": chunk["scenario_id"],
|
||||
"document_id": chunk["document_id"],
|
||||
"source": chunk["source"],
|
||||
"chunk_id": chunk["chunk_id"],
|
||||
"created_at": chunk["created_at"],
|
||||
}
|
||||
for chunk in chunks
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def query_chunks(
|
||||
scenario_id: str,
|
||||
query: str,
|
||||
collection: str,
|
||||
top_k: int = 5,
|
||||
document_ids: list[int] | None = None,
|
||||
store_path: str | Path | None = None,
|
||||
) -> list[dict]:
|
||||
"""执行向量检索,并把 Chroma 原始结果转换为统一引用结构。"""
|
||||
client = _client(store_path)
|
||||
chroma_collection = client.get_or_create_collection(collection)
|
||||
where: dict = {"scenario_id": scenario_id}
|
||||
if document_ids:
|
||||
where = {
|
||||
"$and": [
|
||||
{"scenario_id": scenario_id},
|
||||
{"document_id": {"$in": document_ids}},
|
||||
]
|
||||
}
|
||||
embedding = _embedding_provider().embed_texts([query])[0]
|
||||
result = chroma_collection.query(
|
||||
query_embeddings=[embedding],
|
||||
n_results=top_k,
|
||||
where=where,
|
||||
include=["documents", "metadatas", "distances"],
|
||||
)
|
||||
chunks = []
|
||||
documents = result.get("documents", [[]])[0]
|
||||
metadatas = result.get("metadatas", [[]])[0]
|
||||
distances = result.get("distances", [[]])[0]
|
||||
for content, metadata, distance in zip(documents, metadatas, distances):
|
||||
chunks.append(
|
||||
{
|
||||
"scenario_id": metadata.get("scenario_id"),
|
||||
"document_id": metadata.get("document_id"),
|
||||
"collection": collection,
|
||||
"source": metadata.get("source"),
|
||||
"chunk_id": metadata.get("chunk_id"),
|
||||
"content": content,
|
||||
"created_at": metadata.get("created_at"),
|
||||
"score": round(1 / (1 + float(distance)), 4),
|
||||
}
|
||||
)
|
||||
return chunks
|
||||
@@ -1,171 +0,0 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .chroma_store import upsert_chunks
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngestResult:
|
||||
"""RAG 入库统一返回结构,供 Documents 模块稳定消费。"""
|
||||
success: bool
|
||||
chunks_count: int = 0
|
||||
error: str = ""
|
||||
|
||||
|
||||
def ingest_document(
|
||||
scenario_id: str,
|
||||
source_file: str,
|
||||
text: str,
|
||||
collection: str,
|
||||
document_id: int | None = None,
|
||||
store_path: str | Path | None = None,
|
||||
) -> IngestResult:
|
||||
"""
|
||||
将单个文档文本切分后写入知识库。
|
||||
|
||||
运行策略:
|
||||
- 如果显式传入 `store_path`,说明当前是测试或降级模式,走本地 JSON 存储。
|
||||
- 如果未传入且环境可用 chromadb,则走真实 Chroma 持久化。
|
||||
"""
|
||||
if not text.strip():
|
||||
return IngestResult(success=False, error="文档内容为空")
|
||||
if _should_use_chroma(store_path):
|
||||
return _ingest_chroma_document(
|
||||
document_id=document_id,
|
||||
scenario_id=scenario_id,
|
||||
source_file=source_file,
|
||||
text=text,
|
||||
collection=collection,
|
||||
)
|
||||
resolved_store_path = Path(store_path) if store_path else _default_store_path()
|
||||
chunks = _build_chunks(
|
||||
scenario_id=scenario_id,
|
||||
source_file=source_file,
|
||||
text=text,
|
||||
collection=collection,
|
||||
document_id=document_id,
|
||||
chunk_id_prefix=source_file,
|
||||
)
|
||||
persisted_chunks = _filter_out_same_document_chunks(
|
||||
_load_store(resolved_store_path),
|
||||
scenario_id=scenario_id,
|
||||
collection=collection,
|
||||
document_id=document_id,
|
||||
)
|
||||
_save_store(resolved_store_path, [*persisted_chunks, *chunks])
|
||||
return IngestResult(success=True, chunks_count=len(chunks))
|
||||
|
||||
|
||||
def _should_use_chroma(store_path: str | Path | None) -> bool:
|
||||
"""只在未指定测试存储路径且安装 chromadb 时启用真实向量库。"""
|
||||
return store_path is None and importlib.util.find_spec("chromadb") is not None
|
||||
|
||||
|
||||
def _default_store_path() -> Path:
|
||||
return Path(settings.CHROMA_PATH) / "rag_store.json"
|
||||
|
||||
|
||||
def _load_store(store_path: Path) -> list[dict]:
|
||||
if not store_path.exists():
|
||||
return []
|
||||
with store_path.open("r", encoding="utf-8") as file:
|
||||
return json.load(file)
|
||||
|
||||
|
||||
def _save_store(store_path: Path, chunks: list[dict]) -> None:
|
||||
store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with store_path.open("w", encoding="utf-8") as file:
|
||||
json.dump(chunks, file, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _split_text(text: str, chunk_size: int = 800, overlap: int = 120) -> list[str]:
|
||||
"""
|
||||
使用固定窗口 + overlap 切分文本。
|
||||
|
||||
该策略简单但稳定,便于解释:
|
||||
- chunk_size 控制每个片段最大长度
|
||||
- overlap 保证相邻片段共享上下文,降低边界信息丢失
|
||||
"""
|
||||
normalized = re.sub(r"\s+", " ", text).strip()
|
||||
if not normalized:
|
||||
return []
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < len(normalized):
|
||||
end = start + chunk_size
|
||||
chunks.append(normalized[start:end])
|
||||
if end >= len(normalized):
|
||||
break
|
||||
start = max(end - overlap, start + 1)
|
||||
return chunks
|
||||
|
||||
|
||||
def _build_chunks(
|
||||
scenario_id: str,
|
||||
source_file: str,
|
||||
text: str,
|
||||
collection: str,
|
||||
document_id: int | None,
|
||||
chunk_id_prefix: str,
|
||||
) -> list[dict]:
|
||||
"""把原始文本切分并封装为统一 chunk 结构。"""
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
return [
|
||||
{
|
||||
"scenario_id": scenario_id,
|
||||
"document_id": document_id,
|
||||
"collection": collection,
|
||||
"source": source_file,
|
||||
"chunk_id": f"{scenario_id}:{chunk_id_prefix}:{index}",
|
||||
"content": chunk_text,
|
||||
"created_at": created_at,
|
||||
}
|
||||
for index, chunk_text in enumerate(_split_text(text), start=1)
|
||||
]
|
||||
|
||||
|
||||
def _filter_out_same_document_chunks(
|
||||
chunks: list[dict],
|
||||
scenario_id: str,
|
||||
collection: str,
|
||||
document_id: int | None,
|
||||
) -> list[dict]:
|
||||
"""重新入库同一 document_id 时,先删除旧 chunk,避免重复检索。"""
|
||||
return [
|
||||
chunk
|
||||
for chunk in chunks
|
||||
if not (
|
||||
chunk.get("document_id") == document_id
|
||||
and chunk.get("scenario_id") == scenario_id
|
||||
and chunk.get("collection") == collection
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _ingest_chroma_document(
|
||||
document_id: int | None,
|
||||
scenario_id: str,
|
||||
source_file: str,
|
||||
text: str,
|
||||
collection: str,
|
||||
) -> IngestResult:
|
||||
"""真实 Chroma 模式的入库分支。"""
|
||||
chunks = _build_chunks(
|
||||
scenario_id=scenario_id,
|
||||
source_file=source_file,
|
||||
text=text,
|
||||
collection=collection,
|
||||
document_id=document_id,
|
||||
chunk_id_prefix=str(document_id or source_file),
|
||||
)
|
||||
try:
|
||||
upsert_chunks(collection=collection, chunks=chunks)
|
||||
except Exception as exc:
|
||||
return IngestResult(success=False, error=str(exc))
|
||||
return IngestResult(success=True, chunks_count=len(chunks))
|
||||
@@ -1,105 +0,0 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .chroma_store import query_chunks
|
||||
|
||||
|
||||
def retrieve(
|
||||
scenario_id: str,
|
||||
query: str,
|
||||
collection: str,
|
||||
top_k: int = 5,
|
||||
document_ids: list[int] | None = None,
|
||||
store_path: str | Path | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
统一对外提供检索入口。
|
||||
|
||||
与 ingest_document 保持一致:
|
||||
- 真实运行优先走 Chroma
|
||||
- 测试或降级模式走本地 JSON + 轻量文本打分
|
||||
"""
|
||||
if _should_use_chroma(store_path):
|
||||
return query_chunks(
|
||||
scenario_id=scenario_id,
|
||||
query=query,
|
||||
collection=collection,
|
||||
top_k=top_k,
|
||||
document_ids=document_ids,
|
||||
)
|
||||
resolved_store_path = Path(store_path) if store_path else _default_store_path()
|
||||
query_tokens = _tokens(query)
|
||||
allowed_document_ids = set(document_ids or [])
|
||||
scored_chunks = []
|
||||
for chunk in _load_store(resolved_store_path):
|
||||
if not _matches_scope(
|
||||
chunk=chunk,
|
||||
scenario_id=scenario_id,
|
||||
collection=collection,
|
||||
allowed_document_ids=allowed_document_ids,
|
||||
):
|
||||
continue
|
||||
score = _score(query_tokens, chunk.get("content", ""))
|
||||
if score <= 0:
|
||||
continue
|
||||
scored_chunks.append({**chunk, "score": score})
|
||||
return sorted(scored_chunks, key=lambda item: item["score"], reverse=True)[:top_k]
|
||||
|
||||
|
||||
def _should_use_chroma(store_path: str | Path | None) -> bool:
|
||||
return store_path is None and importlib.util.find_spec("chromadb") is not None
|
||||
|
||||
|
||||
def _default_store_path() -> Path:
|
||||
return Path(settings.CHROMA_PATH) / "rag_store.json"
|
||||
|
||||
|
||||
def _load_store(store_path: Path) -> list[dict]:
|
||||
if not store_path.exists():
|
||||
return []
|
||||
with store_path.open("r", encoding="utf-8") as file:
|
||||
return json.load(file)
|
||||
|
||||
|
||||
def _matches_scope(
|
||||
chunk: dict,
|
||||
scenario_id: str,
|
||||
collection: str,
|
||||
allowed_document_ids: set[int],
|
||||
) -> bool:
|
||||
"""先按场景、collection 和可选文档范围过滤,再进行相关性打分。"""
|
||||
if chunk.get("scenario_id") != scenario_id:
|
||||
return False
|
||||
if chunk.get("collection") != collection:
|
||||
return False
|
||||
if allowed_document_ids and chunk.get("document_id") not in allowed_document_ids:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
"""
|
||||
兼容中英文的轻量分词策略。
|
||||
|
||||
该分词仅用于 fallback 模式,不替代真实向量检索:
|
||||
- 英文/数字按词提取
|
||||
- 中文按连续词片段和单字同时保留,提升短查询命中率
|
||||
"""
|
||||
lowered = text.lower()
|
||||
ascii_tokens = set(re.findall(r"[a-z0-9_]+", lowered))
|
||||
cjk_tokens = set(re.findall(r"[\u4e00-\u9fff]{2,}", lowered))
|
||||
chars = {char for char in lowered if "\u4e00" <= char <= "\u9fff"}
|
||||
return ascii_tokens | cjk_tokens | chars
|
||||
|
||||
|
||||
def _score(query_tokens: set[str], content: str) -> float:
|
||||
"""使用交集占比计算一个便于排序的简化相关性分数。"""
|
||||
content_tokens = _tokens(content)
|
||||
if not query_tokens or not content_tokens:
|
||||
return 0.0
|
||||
overlap = query_tokens & content_tokens
|
||||
return round(len(overlap) / len(query_tokens), 4)
|
||||
@@ -1,22 +0,0 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResult:
|
||||
"""
|
||||
Agent Core 对 Django 层暴露的统一结果对象。
|
||||
|
||||
任何底层编排实现都必须返回这一结构,确保:
|
||||
- Chat 页面有稳定字段可展示
|
||||
- Audit 模块有稳定字段可落库
|
||||
- 未来替换编排引擎时不影响 Django 业务层
|
||||
"""
|
||||
answer: str = ""
|
||||
structured_output: dict = field(default_factory=dict)
|
||||
references: list = field(default_factory=list)
|
||||
tool_calls: list = field(default_factory=list)
|
||||
raw_output: str = ""
|
||||
model_name: str = "mock-model"
|
||||
latency_ms: int = 0
|
||||
status: str = "success"
|
||||
error: str = ""
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
SUPPORTED_OUTPUT_TYPES = {
|
||||
"general_answer",
|
||||
"document_review_report",
|
||||
"ticket_response",
|
||||
"quality_report",
|
||||
"risk_audit_report",
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import json
|
||||
|
||||
from .schemas.outputs import SUPPORTED_OUTPUT_TYPES
|
||||
|
||||
|
||||
# 按输出类型声明页面和审计日志真正需要消费的结构化字段。
|
||||
# 这里不追求复杂 schema 框架,优先保证字段稳定、可读、易讲解。
|
||||
OUTPUT_FIELD_TEMPLATES = {
|
||||
"general_answer": {
|
||||
"answer": "",
|
||||
"confidence": "medium",
|
||||
"references": [],
|
||||
},
|
||||
"document_review_report": {
|
||||
"summary": "",
|
||||
"issues": [],
|
||||
"risk_level": "medium",
|
||||
"suggestions": [],
|
||||
"missing_items": [],
|
||||
"references": [],
|
||||
},
|
||||
"ticket_response": {
|
||||
"reply": "",
|
||||
"category": "general",
|
||||
"priority": "medium",
|
||||
"suggested_action": "",
|
||||
"need_human_review": False,
|
||||
},
|
||||
"quality_report": {
|
||||
"summary": "",
|
||||
"possible_causes": [],
|
||||
"evidence": [],
|
||||
"risk_level": "medium",
|
||||
"suggested_actions": [],
|
||||
"references": [],
|
||||
},
|
||||
"risk_audit_report": {
|
||||
"summary": "",
|
||||
"risk_points": [],
|
||||
"risk_level": "medium",
|
||||
"suggestions": [],
|
||||
"references": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_response_schema_hint(output_type: str) -> dict:
|
||||
"""返回给 LLM 的结构化提示,帮助模型尽量输出稳定 JSON。"""
|
||||
normalized_output_type = normalize_output_type(output_type)
|
||||
return {
|
||||
"output_type": normalized_output_type,
|
||||
"fields": list(OUTPUT_FIELD_TEMPLATES[normalized_output_type].keys()),
|
||||
}
|
||||
|
||||
|
||||
def normalize_output_type(output_type: str) -> str:
|
||||
"""对外部配置做轻量归一化,避免拼写差异导致解析分支混乱。"""
|
||||
if output_type in SUPPORTED_OUTPUT_TYPES:
|
||||
return output_type
|
||||
return "general_answer"
|
||||
|
||||
|
||||
def parse_structured_output(raw_content: str, output_type: str) -> tuple[dict, str]:
|
||||
"""
|
||||
优先将模型输出解析为 JSON。
|
||||
|
||||
返回值:
|
||||
- structured_output: 页面和审计日志可直接消费的标准结构
|
||||
- parse_mode: `json` 表示成功解析,`fallback` 表示降级处理
|
||||
"""
|
||||
normalized_output_type = normalize_output_type(output_type)
|
||||
parsed = _try_parse_json_object(raw_content)
|
||||
if parsed is None:
|
||||
return build_fallback_structured_output(
|
||||
output_type=normalized_output_type,
|
||||
raw_content=raw_content,
|
||||
), "fallback"
|
||||
|
||||
template = {
|
||||
"output_type": normalized_output_type,
|
||||
"parse_mode": "json",
|
||||
}
|
||||
template.update(OUTPUT_FIELD_TEMPLATES[normalized_output_type])
|
||||
template.update(parsed)
|
||||
return template, "json"
|
||||
|
||||
|
||||
def build_fallback_structured_output(output_type: str, raw_content: str) -> dict:
|
||||
"""当模型没有输出合法 JSON 时,仍然构造一个稳定的展示结构。"""
|
||||
normalized_output_type = normalize_output_type(output_type)
|
||||
structured_output = {
|
||||
"output_type": normalized_output_type,
|
||||
"parse_mode": "fallback",
|
||||
}
|
||||
structured_output.update(OUTPUT_FIELD_TEMPLATES[normalized_output_type])
|
||||
|
||||
if normalized_output_type == "general_answer":
|
||||
structured_output["answer"] = raw_content
|
||||
return structured_output
|
||||
if normalized_output_type == "document_review_report":
|
||||
structured_output["summary"] = raw_content
|
||||
return structured_output
|
||||
if normalized_output_type == "ticket_response":
|
||||
structured_output["reply"] = raw_content
|
||||
return structured_output
|
||||
if normalized_output_type == "quality_report":
|
||||
structured_output["summary"] = raw_content
|
||||
return structured_output
|
||||
|
||||
structured_output["summary"] = raw_content
|
||||
return structured_output
|
||||
|
||||
|
||||
def extract_answer_from_structured_output(structured_output: dict, raw_content: str) -> str:
|
||||
"""从结构化结果里提取页面主回答,保证不同输出类型有统一入口。"""
|
||||
for field_name in ("answer", "reply", "summary"):
|
||||
value = structured_output.get(field_name)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return raw_content.strip()
|
||||
|
||||
|
||||
def _try_parse_json_object(raw_content: str) -> dict | None:
|
||||
"""支持纯 JSON 或被 Markdown 代码块包裹的 JSON。"""
|
||||
content = raw_content.strip()
|
||||
if not content:
|
||||
return None
|
||||
candidates = [content]
|
||||
if content.startswith("```"):
|
||||
stripped = content.strip("`").strip()
|
||||
if stripped.lower().startswith("json"):
|
||||
stripped = stripped[4:].strip()
|
||||
candidates.append(stripped)
|
||||
|
||||
for candidate in candidates:
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
@@ -1,70 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from .tools.builtin_tools import BUILTIN_TOOLS
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""
|
||||
统一管理工具注册、查询和执行。
|
||||
|
||||
设计目标:
|
||||
- 让 Orchestrator 只关心“声明了哪些工具”,不关心工具如何存放。
|
||||
- 固化统一的工具调用结果结构,便于页面展示和审计日志保存。
|
||||
- 后续新增业务工具时,只需要注册函数,不必改调用协议。
|
||||
"""
|
||||
|
||||
def __init__(self, initial_tools: dict[str, Callable] | None = None):
|
||||
self._tools: dict[str, Callable] = dict(initial_tools or {})
|
||||
|
||||
def register(self, tool_name: str, tool_func: Callable) -> None:
|
||||
"""注册一个可通过名称调用的工具函数。"""
|
||||
self._tools[tool_name] = tool_func
|
||||
|
||||
def get(self, tool_name: str) -> Callable | None:
|
||||
"""按名称返回工具函数;未注册时返回 None。"""
|
||||
return self._tools.get(tool_name)
|
||||
|
||||
def run(self, tool_name: str, **kwargs) -> dict:
|
||||
"""
|
||||
执行单个工具,并返回统一结果结构。
|
||||
|
||||
统一返回值是审计日志、页面展示和后续 Agent 编排共享的协议。
|
||||
即使工具不存在或执行失败,也返回可消费的失败结果,而不是抛异常。
|
||||
"""
|
||||
tool = self.get(tool_name)
|
||||
if tool is None:
|
||||
return {
|
||||
"tool_name": tool_name,
|
||||
"success": False,
|
||||
"arguments": kwargs,
|
||||
"result": {},
|
||||
"error": "工具未注册",
|
||||
}
|
||||
try:
|
||||
return {
|
||||
"tool_name": tool_name,
|
||||
"success": True,
|
||||
"arguments": kwargs,
|
||||
"result": tool(**kwargs),
|
||||
"error": "",
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"tool_name": tool_name,
|
||||
"success": False,
|
||||
"arguments": kwargs,
|
||||
"result": {},
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
# 默认注册表承载项目内置工具,便于当前 V1 直接复用。
|
||||
DEFAULT_TOOL_REGISTRY = ToolRegistry(BUILTIN_TOOLS)
|
||||
|
||||
|
||||
def run_declared_tools(tool_names: list[str], user_input: str) -> list[dict]:
|
||||
"""按场景声明顺序执行工具,保证结果顺序与配置顺序一致。"""
|
||||
return [
|
||||
DEFAULT_TOOL_REGISTRY.run(tool_name, user_input=user_input)
|
||||
for tool_name in tool_names
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import re
|
||||
|
||||
|
||||
def calculate_rate(user_input: str) -> dict:
|
||||
"""
|
||||
从自然语言中提取两个数值并计算比例。
|
||||
|
||||
V1 目标不是构建复杂公式引擎,而是提供一个可演示的“业务工具”示例:
|
||||
只要输入中出现两个数字,就将其解释为“已完成值 / 总数”。
|
||||
"""
|
||||
numbers = [float(item) for item in re.findall(r"\d+(?:\.\d+)?", user_input)]
|
||||
if len(numbers) < 2:
|
||||
return {
|
||||
"success": False,
|
||||
"rate": 0.0,
|
||||
"numerator": 0.0,
|
||||
"denominator": 0.0,
|
||||
"note": "未能从输入中提取两个数字,无法计算比例。",
|
||||
}
|
||||
numerator, denominator = numbers[0], numbers[1]
|
||||
if denominator == 0:
|
||||
return {
|
||||
"success": False,
|
||||
"rate": 0.0,
|
||||
"numerator": numerator,
|
||||
"denominator": denominator,
|
||||
"note": "分母为 0,无法计算比例。",
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"numerator": numerator,
|
||||
"denominator": denominator,
|
||||
"rate": round(numerator / denominator, 4),
|
||||
"note": "已按输入中的前两个数字完成比例计算。",
|
||||
}
|
||||
|
||||
|
||||
def query_demo_records(user_input: str) -> dict:
|
||||
"""
|
||||
查询示例业务记录。
|
||||
|
||||
该工具依赖 Audit 模块中的 DemoBusinessRecord 演示表,用于证明
|
||||
“场景 + 结构化数据 + 工具调用”可以组成更可信的业务 Agent。
|
||||
"""
|
||||
try:
|
||||
from apps.audit.models import DemoBusinessRecord
|
||||
except Exception as exc:
|
||||
return {"records": [], "error": str(exc)}
|
||||
|
||||
queryset = DemoBusinessRecord.objects.all()
|
||||
tokens = {token.strip().lower() for token in user_input.split() if token.strip()}
|
||||
scenario_ids = set(queryset.values_list("scenario_id", flat=True))
|
||||
record_types = set(queryset.values_list("record_type", flat=True))
|
||||
matched_scenario_ids = scenario_ids & tokens
|
||||
matched_record_types = record_types & tokens
|
||||
if matched_scenario_ids:
|
||||
queryset = queryset.filter(scenario_id__in=matched_scenario_ids)
|
||||
if matched_record_types:
|
||||
queryset = queryset.filter(record_type__in=matched_record_types)
|
||||
records = [
|
||||
{
|
||||
"id": record.id,
|
||||
"scenario_id": record.scenario_id,
|
||||
"record_type": record.record_type,
|
||||
"title": record.title,
|
||||
"payload": record.payload,
|
||||
}
|
||||
for record in queryset[:20]
|
||||
]
|
||||
return {"records": records}
|
||||
|
||||
|
||||
def check_required_fields(user_input: str) -> dict:
|
||||
"""
|
||||
检查输入中声明的必填项是否全部出现。
|
||||
|
||||
约定格式示例:
|
||||
“请检查必填项:合同编号、供应商、金额。当前只提供了合同编号和金额。”
|
||||
"""
|
||||
required_match = re.search(r"必填项[::](.+?)(?:。|\.)", user_input)
|
||||
provided_match = re.search(r"(?:当前|已|仅)?提供了(.+?)(?:。|\.)", user_input)
|
||||
required_fields = _split_cn_items(required_match.group(1) if required_match else "")
|
||||
provided_fields = set(_split_cn_items(provided_match.group(1) if provided_match else ""))
|
||||
missing_fields = [field for field in required_fields if field not in provided_fields]
|
||||
return {
|
||||
"required_fields": required_fields,
|
||||
"provided_fields": list(provided_fields),
|
||||
"missing_fields": missing_fields,
|
||||
"note": "已根据输入中的“必填项/提供了”描述完成检查。",
|
||||
}
|
||||
|
||||
|
||||
def generate_action_items(user_input: str) -> dict:
|
||||
"""
|
||||
生成最小可执行行动项。
|
||||
|
||||
该工具主要用于演示“模型回答之外,还可以得到结构化待办建议”。
|
||||
"""
|
||||
return {
|
||||
"items": [
|
||||
"先确认问题背景和适用场景。",
|
||||
f"围绕当前问题继续核实:{user_input}",
|
||||
"根据知识库和审计结果安排下一步处理动作。",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _split_cn_items(raw_text: str) -> list[str]:
|
||||
"""将中文顿号、逗号和连接词分隔的字段串切分为列表。"""
|
||||
normalized = (
|
||||
raw_text.replace("和", "、")
|
||||
.replace("以及", "、")
|
||||
.replace(",", "、")
|
||||
.replace(",", "、")
|
||||
)
|
||||
return [item.strip(" 。.") for item in normalized.split("、") if item.strip(" 。.")]
|
||||
|
||||
|
||||
BUILTIN_TOOLS = {
|
||||
"calculate_rate": calculate_rate,
|
||||
"query_demo_records": query_demo_records,
|
||||
"check_required_fields": check_required_fields,
|
||||
"generate_action_items": generate_action_items,
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import AgentAuditLog, DemoBusinessRecord
|
||||
|
||||
|
||||
@admin.register(AgentAuditLog)
|
||||
class AgentAuditLogAdmin(admin.ModelAdmin):
|
||||
"""便于在 Django Admin 中快速查看一次 Agent 执行的关键信息。"""
|
||||
list_display = ("id", "scenario_name", "status", "model_name", "latency_ms", "created_at")
|
||||
list_filter = ("status", "scenario_id")
|
||||
search_fields = ("scenario_id", "scenario_name", "user_input", "final_answer")
|
||||
|
||||
|
||||
@admin.register(DemoBusinessRecord)
|
||||
class DemoBusinessRecordAdmin(admin.ModelAdmin):
|
||||
"""管理工具查询依赖的示例业务记录。"""
|
||||
list_display = ("id", "title", "scenario_id", "record_type", "created_at")
|
||||
list_filter = ("scenario_id", "record_type")
|
||||
search_fields = ("title", "scenario_id", "record_type")
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuditConfig(AppConfig):
|
||||
"""Audit 模块应用配置。"""
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.audit"
|
||||
@@ -1,46 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-29 13:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AgentAuditLog",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("scenario_id", models.CharField(db_index=True, max_length=100)),
|
||||
("scenario_name", models.CharField(blank=True, max_length=200)),
|
||||
("user_input", models.TextField()),
|
||||
("retrieved_chunks", models.JSONField(blank=True, default=list)),
|
||||
("tool_calls", models.JSONField(blank=True, default=list)),
|
||||
("structured_output", models.JSONField(blank=True, default=dict)),
|
||||
("final_answer", models.TextField(blank=True)),
|
||||
("raw_output", models.TextField(blank=True)),
|
||||
("model_name", models.CharField(blank=True, max_length=100)),
|
||||
("latency_ms", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(db_index=True, default="success", max_length=20),
|
||||
),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated for V1 demo business records.
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("audit", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DemoBusinessRecord",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("scenario_id", models.CharField(db_index=True, max_length=100)),
|
||||
("record_type", models.CharField(db_index=True, max_length=100)),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("payload", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,68 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class AgentAuditLog(models.Model):
|
||||
"""
|
||||
保存一次 Agent 执行的完整审计快照。
|
||||
|
||||
该模型是“系统可解释性”的核心:
|
||||
- 对话页负责触发执行
|
||||
- Agent Core 负责生成结果
|
||||
- Audit 模型负责长期保存输入、引用、工具调用和模型输出
|
||||
"""
|
||||
# 审计状态需要同时服务数据库检索和前端展示。
|
||||
STATUS_SUCCESS = "success"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
scenario_id = models.CharField(max_length=100, db_index=True)
|
||||
scenario_name = models.CharField(max_length=200, blank=True)
|
||||
user_input = models.TextField()
|
||||
retrieved_chunks = models.JSONField(default=list, blank=True)
|
||||
tool_calls = models.JSONField(default=list, blank=True)
|
||||
structured_output = models.JSONField(default=dict, blank=True)
|
||||
final_answer = models.TextField(blank=True)
|
||||
raw_output = models.TextField(blank=True)
|
||||
model_name = models.CharField(max_length=100, blank=True)
|
||||
latency_ms = models.PositiveIntegerField(default=0)
|
||||
status = models.CharField(max_length=20, default=STATUS_SUCCESS, db_index=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.scenario_name or self.scenario_id} #{self.pk}"
|
||||
|
||||
def get_status_display_text(self) -> str:
|
||||
"""返回更适合页面展示的中文状态。"""
|
||||
return {
|
||||
self.STATUS_SUCCESS: "执行成功",
|
||||
self.STATUS_FAILED: "执行失败",
|
||||
}.get(self.status, self.status)
|
||||
|
||||
def get_user_input_summary(self, max_length: int = 28) -> str:
|
||||
"""在列表页展示用户输入摘要,避免长文本撑破表格。"""
|
||||
if len(self.user_input) <= max_length:
|
||||
return self.user_input
|
||||
return f"{self.user_input[:max_length]}..."
|
||||
|
||||
|
||||
class DemoBusinessRecord(models.Model):
|
||||
"""
|
||||
演示用业务记录表。
|
||||
|
||||
该表不直接参与页面主流程,而是供内置工具 `query_demo_records`
|
||||
查询,证明 Agent 除知识库外也可以结合结构化业务数据。
|
||||
"""
|
||||
scenario_id = models.CharField(max_length=100, db_index=True)
|
||||
record_type = models.CharField(max_length=100, db_index=True)
|
||||
title = models.CharField(max_length=255)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
@@ -1,57 +0,0 @@
|
||||
from agent_core.results import AgentResult
|
||||
|
||||
from .models import AgentAuditLog
|
||||
|
||||
|
||||
def create_audit_log(
|
||||
scenario_id: str,
|
||||
scenario_name: str,
|
||||
user_input: str,
|
||||
agent_result: AgentResult,
|
||||
) -> AgentAuditLog:
|
||||
"""
|
||||
将一次 Agent 执行结果落库为审计日志。
|
||||
|
||||
设计原则:
|
||||
- 成功与失败都必须记录,方便复盘整条执行链路
|
||||
- 敏感信息在写库前先脱敏,避免误存 API Key
|
||||
- 对前端和 Django Model 统一输出稳定字段
|
||||
"""
|
||||
return AgentAuditLog.objects.create(
|
||||
scenario_id=scenario_id,
|
||||
scenario_name=scenario_name,
|
||||
user_input=user_input,
|
||||
retrieved_chunks=agent_result.references,
|
||||
tool_calls=agent_result.tool_calls,
|
||||
structured_output=agent_result.structured_output,
|
||||
final_answer=agent_result.answer,
|
||||
raw_output=agent_result.raw_output,
|
||||
model_name=agent_result.model_name,
|
||||
latency_ms=max(agent_result.latency_ms, 0),
|
||||
status=agent_result.status,
|
||||
error_message=mask_sensitive_text(agent_result.error),
|
||||
)
|
||||
|
||||
|
||||
def mask_sensitive_text(value: str) -> str:
|
||||
"""
|
||||
对错误文本中的敏感配置进行脱敏。
|
||||
|
||||
当前至少处理:
|
||||
- `LLM_API_KEY=...`
|
||||
- `EMBEDDING_API_KEY=...`
|
||||
"""
|
||||
masked = value
|
||||
for marker in ("LLM_API_KEY=", "EMBEDDING_API_KEY="):
|
||||
masked = _mask_token_after_marker(masked, marker)
|
||||
return masked
|
||||
|
||||
|
||||
def _mask_token_after_marker(value: str, marker: str) -> str:
|
||||
"""将 marker 后紧跟的 token 替换为脱敏占位符。"""
|
||||
if marker not in value:
|
||||
return value
|
||||
prefix, _, suffix = value.partition(marker)
|
||||
secret, separator, rest = suffix.partition(" ")
|
||||
masked_secret = "sk-***" if secret.startswith("sk-") else "***"
|
||||
return f"{prefix}{marker}{masked_secret}{separator}{rest}"
|
||||
@@ -1,12 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = "audit"
|
||||
|
||||
# V1 的审计功能由列表页和详情页组成,暂不拆分页或复杂筛选接口。
|
||||
urlpatterns = [
|
||||
path("", views.log_list, name="list"),
|
||||
path("<int:log_id>/", views.log_detail, name="detail"),
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from .models import AgentAuditLog
|
||||
|
||||
|
||||
def log_list(request):
|
||||
# 列表页支持按场景筛选,方便演示时快速定位同一类场景的执行记录。
|
||||
scenario_id = (request.GET.get("scenario_id") or "").strip()
|
||||
logs = AgentAuditLog.objects.all()
|
||||
if scenario_id:
|
||||
logs = logs.filter(scenario_id=scenario_id)
|
||||
return render(
|
||||
request,
|
||||
"audit/log_list.html",
|
||||
{
|
||||
"logs": logs,
|
||||
"selected_scenario_id": scenario_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def log_detail(request, log_id: int):
|
||||
# 详情页只负责按主键加载审计快照并渲染;
|
||||
# 所有脱敏和字段映射都应在服务层完成。
|
||||
audit_log = get_object_or_404(AgentAuditLog, pk=log_id)
|
||||
return render(request, "audit/log_detail.html", {"log": audit_log})
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChatConfig(AppConfig):
|
||||
"""Chat 模块应用配置。"""
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.chat"
|
||||
@@ -1,40 +0,0 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class ChatForm(forms.Form):
|
||||
# 该表单只负责收集用户问题和可选文档范围,
|
||||
# 不承载任何 Agent 业务逻辑,便于在 View 层保持轻量。
|
||||
message = forms.CharField(
|
||||
label="问题",
|
||||
max_length=4000,
|
||||
error_messages={
|
||||
"required": "请输入要咨询的问题。",
|
||||
"max_length": "问题过长,请控制在 4000 字以内。",
|
||||
},
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 8,
|
||||
"placeholder": "例如:请结合已上传 SOP,分析当前异常的原因、风险等级和建议动作。",
|
||||
}
|
||||
),
|
||||
)
|
||||
document_ids = forms.MultipleChoiceField(
|
||||
label="文档范围",
|
||||
required=False,
|
||||
choices=(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
error_messages={"invalid_choice": "请选择当前场景下已入库的文档。"},
|
||||
)
|
||||
|
||||
def __init__(self, *args, documents=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
documents = documents or []
|
||||
# 仅允许选择当前场景且已完成入库的文档,
|
||||
# 避免前端把无效文件范围传入 Agent Core。
|
||||
self.fields["document_ids"].choices = [
|
||||
(str(document.id), document.original_name) for document in documents
|
||||
]
|
||||
|
||||
def clean_document_ids(self):
|
||||
# View 与 Agent Core 都使用整型文档 ID,统一在表单层完成转换。
|
||||
return [int(document_id) for document_id in self.cleaned_data.get("document_ids", [])]
|
||||
@@ -1,11 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = "chat"
|
||||
|
||||
# 当前 V1 仅保留一个场景对话入口,场景详情合并在对话页中展示。
|
||||
urlpatterns = [
|
||||
path("<str:scenario_id>/", views.index, name="index"),
|
||||
]
|
||||
@@ -1,60 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
from agent_core.orchestrator import run_agent
|
||||
from agent_core.results import AgentResult
|
||||
from apps.audit.services import create_audit_log
|
||||
from apps.documents.models import UploadedDocument
|
||||
from apps.scenarios.services import ScenarioNotFound, get_scenario
|
||||
|
||||
from .forms import ChatForm
|
||||
|
||||
|
||||
def index(request, scenario_id: str):
|
||||
# View 只负责请求编排、表单校验和模板渲染。
|
||||
# 具体 Agent 执行、审计写入和文档筛选规则分别交给独立模块处理。
|
||||
try:
|
||||
scenario = get_scenario(scenario_id)
|
||||
except ScenarioNotFound:
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"scenario": None,
|
||||
"form": ChatForm(),
|
||||
"error": "场景不存在,请返回首页检查配置。",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
result = None
|
||||
audit_log = None
|
||||
documents = UploadedDocument.objects.filter(
|
||||
scenario_id=scenario["id"],
|
||||
status=UploadedDocument.STATUS_INDEXED,
|
||||
)
|
||||
form = ChatForm(request.POST or None, documents=documents)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
message = form.cleaned_data["message"]
|
||||
try:
|
||||
# 只把必要的运行选项传给 Agent Core,避免在 View 中散落模型细节。
|
||||
result = run_agent(
|
||||
scenario,
|
||||
message,
|
||||
options={"document_ids": form.cleaned_data["document_ids"]},
|
||||
)
|
||||
except Exception as exc:
|
||||
result = AgentResult(status="failed", error=str(exc), answer="")
|
||||
audit_log = create_audit_log(scenario["id"], scenario["name"], message, result)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"scenario": scenario,
|
||||
"form": form,
|
||||
"documents": documents,
|
||||
"document_count": documents.count(),
|
||||
"result": result,
|
||||
"audit_log": audit_log,
|
||||
},
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import UploadedDocument
|
||||
|
||||
|
||||
@admin.register(UploadedDocument)
|
||||
class UploadedDocumentAdmin(admin.ModelAdmin):
|
||||
"""管理上传文档及其入库状态,便于后台排查问题。"""
|
||||
list_display = ("id", "original_name", "scenario_id", "file_type", "status", "created_at")
|
||||
list_filter = ("status", "scenario_id", "file_type")
|
||||
search_fields = ("original_name", "scenario_id")
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DocumentsConfig(AppConfig):
|
||||
"""Documents 模块应用配置。"""
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.documents"
|
||||
@@ -1,37 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django import forms
|
||||
|
||||
from apps.scenarios.services import ScenarioNotFound, get_scenario
|
||||
from apps.scenarios.services import list_scenarios
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"}
|
||||
|
||||
|
||||
class DocumentUploadForm(forms.Form):
|
||||
# 使用 ChoiceField 让表单自己维护场景选项,
|
||||
# 这样模板、校验和后续扩展都能围绕一个入口完成。
|
||||
scenario_id = forms.ChoiceField(label="场景", choices=())
|
||||
file = forms.FileField(label="文件")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["scenario_id"].choices = [
|
||||
(scenario["id"], scenario["name"])
|
||||
for scenario in list_scenarios()
|
||||
]
|
||||
|
||||
def clean_scenario_id(self):
|
||||
scenario_id = self.cleaned_data["scenario_id"]
|
||||
try:
|
||||
get_scenario(scenario_id)
|
||||
except ScenarioNotFound as exc:
|
||||
raise forms.ValidationError("场景不存在") from exc
|
||||
return scenario_id
|
||||
|
||||
def clean_file(self):
|
||||
uploaded_file = self.cleaned_data["file"]
|
||||
extension = Path(uploaded_file.name).suffix.lower()
|
||||
if extension not in SUPPORTED_EXTENSIONS:
|
||||
raise forms.ValidationError("仅支持 .txt、.md、.pdf 和 .docx 文件")
|
||||
return uploaded_file
|
||||
@@ -1,42 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-29 13:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UploadedDocument",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("scenario_id", models.CharField(db_index=True, max_length=100)),
|
||||
("original_name", models.CharField(max_length=255)),
|
||||
("file", models.FileField(upload_to="documents/%Y%m%d/")),
|
||||
("file_type", models.CharField(max_length=20)),
|
||||
("size", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(db_index=True, default="uploaded", max_length=20),
|
||||
),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class UploadedDocument(models.Model):
|
||||
"""
|
||||
保存用户上传文档的元数据和入库状态。
|
||||
|
||||
设计上只记录“文件属于哪个场景、当前是否已入库、失败原因是什么”,
|
||||
不把 RAG 细节耦合进模型层。
|
||||
"""
|
||||
# 文档状态用于驱动前端提示和后续可操作项。
|
||||
STATUS_UPLOADED = "uploaded"
|
||||
STATUS_INDEXED = "indexed"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
scenario_id = models.CharField(max_length=100, db_index=True)
|
||||
original_name = models.CharField(max_length=255)
|
||||
file = models.FileField(upload_to="documents/%Y%m%d/")
|
||||
file_type = models.CharField(max_length=20)
|
||||
size = models.PositiveIntegerField(default=0)
|
||||
status = models.CharField(max_length=20, default=STATUS_UPLOADED, db_index=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.original_name
|
||||
|
||||
def get_status_display_text(self) -> str:
|
||||
"""为模板提供更适合演示的中文状态文案。"""
|
||||
return {
|
||||
self.STATUS_UPLOADED: "已上传,待入库",
|
||||
self.STATUS_INDEXED: "已入库,可检索",
|
||||
self.STATUS_FAILED: "入库失败",
|
||||
}.get(self.status, self.status)
|
||||
@@ -1,127 +0,0 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from agent_core.rag.ingest import ingest_document
|
||||
|
||||
from .models import UploadedDocument
|
||||
|
||||
|
||||
def create_uploaded_document(scenario_id: str, uploaded_file) -> UploadedDocument:
|
||||
"""
|
||||
保存上传文件的元数据记录。
|
||||
|
||||
Documents 模块只记录文件与场景关系、原始名称、类型和大小,
|
||||
真正的入库动作由用户后续主动触发,避免上传阶段就耦合 RAG 流程。
|
||||
"""
|
||||
extension = _detect_extension(uploaded_file.name)
|
||||
return UploadedDocument.objects.create(
|
||||
scenario_id=scenario_id,
|
||||
original_name=uploaded_file.name,
|
||||
file=uploaded_file,
|
||||
file_type=extension,
|
||||
size=uploaded_file.size,
|
||||
status=UploadedDocument.STATUS_UPLOADED,
|
||||
)
|
||||
|
||||
|
||||
def extract_text(document: UploadedDocument) -> str:
|
||||
"""
|
||||
根据文档类型选择合适的文本抽取策略。
|
||||
|
||||
V1 的目标是“可演示且稳定”,因此:
|
||||
- `.txt` / `.md` 直接按文本读取
|
||||
- `.pdf` 优先走 pypdf,失败时回退为二进制容错读取
|
||||
- `.docx` 优先解析 Word XML,失败时回退为二进制容错读取
|
||||
"""
|
||||
path = Path(document.file.path)
|
||||
extension = f".{document.file_type.lower().lstrip('.')}"
|
||||
if extension == ".pdf":
|
||||
return _extract_pdf_text(path)
|
||||
if extension == ".docx":
|
||||
return _extract_docx_text(path)
|
||||
return _read_text_file(path)
|
||||
|
||||
|
||||
def index_document(document: UploadedDocument) -> UploadedDocument:
|
||||
"""
|
||||
触发单个文档入库,并把成功/失败状态回写到 UploadedDocument。
|
||||
|
||||
这里故意不抛业务异常给 View:
|
||||
View 层只需要知道“最终状态是什么”,而错误信息统一落到模型字段中,
|
||||
便于页面重试和演示。
|
||||
"""
|
||||
try:
|
||||
text = extract_text(document)
|
||||
ingest_result = ingest_document(
|
||||
document_id=document.id,
|
||||
scenario_id=document.scenario_id,
|
||||
source_file=document.original_name,
|
||||
text=text,
|
||||
collection=document.scenario_id,
|
||||
)
|
||||
_apply_ingest_result(document, ingest_result.success, ingest_result.error)
|
||||
except Exception as exc:
|
||||
_apply_ingest_result(document, success=False, error=str(exc))
|
||||
document.save(update_fields=["status", "error_message", "updated_at"])
|
||||
return document
|
||||
|
||||
|
||||
def _apply_ingest_result(document: UploadedDocument, success: bool, error: str = "") -> None:
|
||||
"""把入库结果映射为 UploadedDocument 的稳定状态字段。"""
|
||||
if success:
|
||||
document.status = UploadedDocument.STATUS_INDEXED
|
||||
document.error_message = ""
|
||||
return
|
||||
document.status = UploadedDocument.STATUS_FAILED
|
||||
document.error_message = error
|
||||
|
||||
|
||||
def _detect_extension(file_name: str) -> str:
|
||||
"""统一将扩展名转成小写且去掉前导点,便于模型字段存储。"""
|
||||
return Path(file_name).suffix.lower().lstrip(".")
|
||||
|
||||
|
||||
def _read_text_file(path: Path) -> str:
|
||||
"""优先按 UTF-8 读取;失败时回退到系统默认编码。"""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return path.read_text()
|
||||
|
||||
|
||||
def _extract_pdf_text(path: Path) -> str:
|
||||
"""优先使用 pypdf 抽取 PDF 文本,失败时回退到容错方案。"""
|
||||
try:
|
||||
import pypdf
|
||||
|
||||
reader = pypdf.PdfReader(str(path))
|
||||
return "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||
except Exception:
|
||||
return _read_binary_text_fallback(path)
|
||||
|
||||
|
||||
def _extract_docx_text(path: Path) -> str:
|
||||
"""提取 Word XML 中的可见文字内容,不追求保留样式。"""
|
||||
try:
|
||||
with ZipFile(path) as archive:
|
||||
document_xml = archive.read("word/document.xml")
|
||||
root = ET.fromstring(document_xml)
|
||||
namespace = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
||||
texts = [node.text for node in root.findall(".//w:t", namespace) if node.text]
|
||||
return "\n".join(texts)
|
||||
except (BadZipFile, KeyError, ET.ParseError):
|
||||
return _read_binary_text_fallback(path)
|
||||
|
||||
|
||||
def _read_binary_text_fallback(path: Path) -> str:
|
||||
"""
|
||||
当结构化抽取失败时,退回到“尽可能保留纯文本”的保底方案。
|
||||
|
||||
该方案不保证版式,但足以支撑 V1 入库和演示。
|
||||
"""
|
||||
data = path.read_bytes()
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]+", " ", text)
|
||||
return text.strip()
|
||||
@@ -1,13 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = "documents"
|
||||
|
||||
# 文档模块对外暴露三个基础动作:列表、上传、手动入库。
|
||||
urlpatterns = [
|
||||
path("", views.document_list, name="list"),
|
||||
path("upload/", views.upload, name="upload"),
|
||||
path("<int:document_id>/index/", views.index, name="index"),
|
||||
]
|
||||
@@ -1,43 +0,0 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from apps.scenarios.services import list_scenarios
|
||||
|
||||
from .forms import DocumentUploadForm
|
||||
from .models import UploadedDocument
|
||||
from .services import create_uploaded_document, index_document
|
||||
|
||||
|
||||
def document_list(request):
|
||||
# 列表页只负责展示文档元数据和可执行操作,不处理入库细节。
|
||||
documents = UploadedDocument.objects.all()
|
||||
return render(request, "documents/document_list.html", {"documents": documents})
|
||||
|
||||
|
||||
def upload(request):
|
||||
# 上传成功后仅保存文件和元数据,是否入库由用户显式触发。
|
||||
if request.method == "POST":
|
||||
form = DocumentUploadForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
create_uploaded_document(form.cleaned_data["scenario_id"], form.cleaned_data["file"])
|
||||
messages.success(request, "文件已上传,可继续执行入库。")
|
||||
return redirect("documents:list")
|
||||
else:
|
||||
form = DocumentUploadForm()
|
||||
return render(
|
||||
request,
|
||||
"documents/upload.html",
|
||||
{"form": form, "scenarios": list_scenarios()},
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
def index(request, document_id: int):
|
||||
document = get_object_or_404(UploadedDocument, pk=document_id)
|
||||
document = index_document(document)
|
||||
if document.status == UploadedDocument.STATUS_INDEXED:
|
||||
messages.success(request, "文档入库成功,当前文档已可参与检索。")
|
||||
else:
|
||||
messages.error(request, "文档入库失败,请检查错误原因后重试。")
|
||||
return redirect("documents:list")
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ScenariosConfig(AppConfig):
|
||||
"""Scenarios 模块应用配置。"""
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.scenarios"
|
||||
@@ -1,110 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
REQUIRED_FIELDS = [
|
||||
("id",),
|
||||
("name",),
|
||||
("description",),
|
||||
("agent", "role"),
|
||||
("agent", "goal"),
|
||||
("agent", "instructions"),
|
||||
("rag", "enabled"),
|
||||
("tools",),
|
||||
("output", "type"),
|
||||
("audit", "enabled"),
|
||||
]
|
||||
|
||||
|
||||
class ScenarioNotFound(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
class ScenarioValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _get_nested(config: dict, path: tuple[str, ...]):
|
||||
value = config
|
||||
for key in path:
|
||||
if not isinstance(value, dict) or key not in value:
|
||||
raise ScenarioValidationError("缺失必填字段: " + ".".join(path))
|
||||
value = value[key]
|
||||
return value
|
||||
|
||||
|
||||
def validate_scenario(config: dict) -> dict:
|
||||
# 仅校验真正影响运行闭环的必填字段;
|
||||
# 页面展示字段允许缺失,并在归一化阶段补默认值。
|
||||
for field_path in REQUIRED_FIELDS:
|
||||
_get_nested(config, field_path)
|
||||
return normalize_scenario(config)
|
||||
|
||||
|
||||
def normalize_scenario(config: dict) -> dict:
|
||||
"""补齐页面和其它模块常用的派生字段,避免模板层重复判断。"""
|
||||
normalized = dict(config)
|
||||
normalized["applicable_questions"] = list(config.get("applicable_questions") or [])
|
||||
normalized["rag"] = dict(config.get("rag", {}))
|
||||
normalized["rag"]["enabled"] = bool(normalized["rag"].get("enabled"))
|
||||
normalized["tools"] = list(config.get("tools") or [])
|
||||
normalized["tool_count"] = len(normalized["tools"])
|
||||
normalized["is_enabled"] = True
|
||||
return normalized
|
||||
|
||||
|
||||
def _scenario_files() -> list[Path]:
|
||||
config_dir = Path(settings.SCENARIO_CONFIG_DIR)
|
||||
if not config_dir.exists():
|
||||
return []
|
||||
return sorted([*config_dir.glob("*.yaml"), *config_dir.glob("*.yml")])
|
||||
|
||||
|
||||
def _read_yaml_file(path: Path) -> dict:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
return yaml.safe_load(file) or {}
|
||||
|
||||
|
||||
def _collect_scenario_load_result() -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
统一读取配置目录中的所有场景文件。
|
||||
|
||||
返回值:
|
||||
- scenarios: 校验通过的场景列表
|
||||
- issues: 非法 YAML / 缺字段等错误摘要,供首页展示
|
||||
"""
|
||||
scenarios = []
|
||||
issues = []
|
||||
for path in _scenario_files():
|
||||
try:
|
||||
config = _read_yaml_file(path)
|
||||
scenarios.append(validate_scenario(config))
|
||||
except (yaml.YAMLError, ScenarioValidationError) as exc:
|
||||
issues.append(
|
||||
{
|
||||
"file_name": path.name,
|
||||
"message": str(exc),
|
||||
}
|
||||
)
|
||||
return scenarios, issues
|
||||
|
||||
|
||||
def list_scenarios() -> list[dict]:
|
||||
# 首页每次读取最新 YAML,便于复试现场快速改题。
|
||||
scenarios, _issues = _collect_scenario_load_result()
|
||||
return scenarios
|
||||
|
||||
|
||||
def list_scenario_issues() -> list[dict]:
|
||||
"""返回配置异常摘要,便于页面明确提示而不是直接 500。"""
|
||||
_scenarios, issues = _collect_scenario_load_result()
|
||||
return issues
|
||||
|
||||
|
||||
def get_scenario(scenario_id: str) -> dict:
|
||||
for scenario in list_scenarios():
|
||||
if scenario["id"] == scenario_id:
|
||||
return scenario
|
||||
raise ScenarioNotFound(f"场景不存在: {scenario_id}")
|
||||
@@ -1,10 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = "scenarios"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
from .services import list_scenario_issues, list_scenarios
|
||||
|
||||
|
||||
def index(request):
|
||||
# 首页只消费服务层给出的场景摘要和错误摘要,
|
||||
# 不自行读取 YAML,更不在 View 里做字段拼装。
|
||||
return render(
|
||||
request,
|
||||
"scenarios/index.html",
|
||||
{
|
||||
"scenarios": list_scenarios(),
|
||||
"scenario_issues": list_scenario_issues(),
|
||||
},
|
||||
)
|
||||
@@ -1,22 +0,0 @@
|
||||
id: document_review
|
||||
name: 文档审核助手
|
||||
description: 检查合同、制度或 SOP 中的风险点和缺失项
|
||||
applicable_questions:
|
||||
- 合同审核
|
||||
- 制度审核
|
||||
agent:
|
||||
role: 文档审核专家
|
||||
goal: 根据审核规则和知识库内容输出结构化审核意见
|
||||
instructions:
|
||||
- 不确定的问题必须标记为需人工复核
|
||||
- 输出必须包含风险等级和修改建议
|
||||
rag:
|
||||
enabled: true
|
||||
collection: document_review
|
||||
top_k: 5
|
||||
tools:
|
||||
- check_required_fields
|
||||
output:
|
||||
type: document_review_report
|
||||
audit:
|
||||
enabled: true
|
||||
@@ -1,22 +0,0 @@
|
||||
id: knowledge_qa
|
||||
name: 知识库问答助手
|
||||
description: 用于 SOP、制度、客服知识库和内部文档问答
|
||||
applicable_questions:
|
||||
- SOP 问答
|
||||
- 制度问答
|
||||
agent:
|
||||
role: 知识库问答专家
|
||||
goal: 基于知识库内容回答用户问题
|
||||
instructions:
|
||||
- 回答必须优先基于检索内容
|
||||
- 不确定时说明缺失信息
|
||||
rag:
|
||||
enabled: true
|
||||
collection: knowledge_qa
|
||||
top_k: 5
|
||||
tools:
|
||||
- generate_action_items
|
||||
output:
|
||||
type: general_answer
|
||||
audit:
|
||||
enabled: true
|
||||
@@ -1,23 +0,0 @@
|
||||
id: quality_analysis
|
||||
name: 质量异常分析助手
|
||||
description: 用于分析生产质量异常、检索 SOP、生成处理建议
|
||||
applicable_questions:
|
||||
- 质量异常分析
|
||||
- 缺陷原因定位
|
||||
agent:
|
||||
role: 质量管理专家
|
||||
goal: 根据用户问题、知识库和工具结果,输出可执行的质量分析报告
|
||||
instructions:
|
||||
- 回答必须基于知识库或工具结果
|
||||
- 涉及质量风险时给出风险等级
|
||||
rag:
|
||||
enabled: true
|
||||
collection: quality_analysis
|
||||
top_k: 5
|
||||
tools:
|
||||
- query_demo_records
|
||||
- calculate_rate
|
||||
output:
|
||||
type: quality_report
|
||||
audit:
|
||||
enabled: true
|
||||
@@ -1,24 +0,0 @@
|
||||
id: risk_audit
|
||||
name: 风险审核助手
|
||||
description: 用于财务、采购、报销和合同风险审核
|
||||
applicable_questions:
|
||||
- 财务审核
|
||||
- 采购审核
|
||||
- 合同风险分析
|
||||
agent:
|
||||
role: 风险审核专家
|
||||
goal: 识别业务材料中的风险点并给出审核建议
|
||||
instructions:
|
||||
- 风险点必须说明依据
|
||||
- 缺失材料要单独列出
|
||||
rag:
|
||||
enabled: true
|
||||
collection: risk_audit
|
||||
top_k: 5
|
||||
tools:
|
||||
- check_required_fields
|
||||
- calculate_rate
|
||||
output:
|
||||
type: risk_audit_report
|
||||
audit:
|
||||
enabled: true
|
||||
@@ -1,23 +0,0 @@
|
||||
id: ticket_assistant
|
||||
name: 工单处理助手
|
||||
description: 用于客服、售后和运维工单的分类与回复建议
|
||||
applicable_questions:
|
||||
- 客服工单
|
||||
- 售后工单
|
||||
agent:
|
||||
role: 工单处理专家
|
||||
goal: 判断工单类别、优先级并生成处理建议
|
||||
instructions:
|
||||
- 需要人工处理时明确标记
|
||||
- 回复建议要简洁可执行
|
||||
rag:
|
||||
enabled: true
|
||||
collection: ticket_assistant
|
||||
top_k: 5
|
||||
tools:
|
||||
- query_demo_records
|
||||
- generate_action_items
|
||||
output:
|
||||
type: ticket_response
|
||||
audit:
|
||||
enabled: true
|
||||
@@ -1,10 +0,0 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./configs:/app/configs
|
||||
@@ -1,343 +0,0 @@
|
||||
# V1 Django Baseline Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the smallest runnable Django baseline that satisfies the current Chinese requirements and design documents.
|
||||
|
||||
**Architecture:** Use a Django monolith with four apps (`scenarios`, `documents`, `chat`, `audit`) plus an independent `agent_core` package. The first implementation returns deterministic mock Agent results so the UI, audit, documents and module boundaries can be verified before real RAG/LLM integration.
|
||||
|
||||
**Tech Stack:** Python 3.13, Django 5.x, PyYAML, pytest, pytest-django, SQLite, Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `requirements.txt`: runtime and test dependencies.
|
||||
- Create `manage.py`, `config/settings.py`, `config/urls.py`, `config/wsgi.py`, `config/asgi.py`: Django project shell.
|
||||
- Create `apps/scenarios/`: YAML scenario loading, homepage, tests.
|
||||
- Create `apps/documents/`: upload model, upload/list/index views, text extraction, tests.
|
||||
- Create `apps/chat/`: message form, chat view, Agent Core call, audit write, tests.
|
||||
- Create `apps/audit/`: audit model, service, list/detail views, tests.
|
||||
- Create `agent_core/`: dataclasses, orchestrator, mock RAG ingest/retrieve, tool registry, structured output parser.
|
||||
- Create `configs/*.yaml`: five required scenarios.
|
||||
- Create `templates/`: minimal Django Templates for pages.
|
||||
- Create `Dockerfile`, `docker-compose.yml`, `.env.example`: one-command startup.
|
||||
|
||||
## Task 1: Dependencies and Django Project Shell
|
||||
|
||||
**Files:**
|
||||
- Create: `requirements.txt`
|
||||
- Create: `manage.py`
|
||||
- Create: `config/__init__.py`
|
||||
- Create: `config/settings.py`
|
||||
- Create: `config/urls.py`
|
||||
- Create: `config/wsgi.py`
|
||||
- Create: `config/asgi.py`
|
||||
- Test: `pytest.ini`
|
||||
|
||||
- [ ] **Step 1: Write failing configuration test**
|
||||
|
||||
Create `tests/test_project_configuration.py`:
|
||||
|
||||
```python
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
def test_core_settings_expose_documented_paths():
|
||||
assert settings.SCENARIO_CONFIG_DIR.name == "configs"
|
||||
assert settings.CHROMA_PATH.name == "chroma"
|
||||
assert settings.MEDIA_ROOT.name == "uploads"
|
||||
|
||||
|
||||
def test_home_url_is_registered(client):
|
||||
response = client.get(reverse("scenarios:index"))
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_project_configuration.py -q`
|
||||
Expected: FAIL because Django project and apps do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement minimal project shell**
|
||||
|
||||
Add dependencies and project files with settings for installed apps, templates, SQLite, media paths, and URL includes.
|
||||
|
||||
- [ ] **Step 4: Run test to verify progress**
|
||||
|
||||
Run: `pytest tests/test_project_configuration.py -q`
|
||||
Expected: either PASS or fail only because `apps.scenarios` is not implemented yet.
|
||||
|
||||
## Task 2: Scenarios Module and Five YAML Configs
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/scenarios/services.py`
|
||||
- Create: `apps/scenarios/views.py`
|
||||
- Create: `apps/scenarios/urls.py`
|
||||
- Create: `apps/scenarios/apps.py`
|
||||
- Create: `configs/knowledge_qa.yaml`
|
||||
- Create: `configs/document_review.yaml`
|
||||
- Create: `configs/ticket_assistant.yaml`
|
||||
- Create: `configs/quality_analysis.yaml`
|
||||
- Create: `configs/risk_audit.yaml`
|
||||
- Create: `templates/scenarios/index.html`
|
||||
- Test: `tests/test_scenarios.py`
|
||||
|
||||
- [ ] **Step 1: Write failing scenario tests**
|
||||
|
||||
```python
|
||||
from apps.scenarios.services import get_scenario, list_scenarios
|
||||
|
||||
|
||||
def test_list_scenarios_loads_five_configs():
|
||||
scenarios = list_scenarios()
|
||||
assert [scenario["id"] for scenario in scenarios] == [
|
||||
"knowledge_qa",
|
||||
"document_review",
|
||||
"ticket_assistant",
|
||||
"quality_analysis",
|
||||
"risk_audit",
|
||||
]
|
||||
|
||||
|
||||
def test_get_scenario_returns_full_agent_config():
|
||||
scenario = get_scenario("quality_analysis")
|
||||
assert scenario["agent"]["role"]
|
||||
assert scenario["rag"]["enabled"] is True
|
||||
assert scenario["output"]["type"] == "quality_report"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_scenarios.py -q`
|
||||
Expected: FAIL because services/configs are missing.
|
||||
|
||||
- [ ] **Step 3: Implement scenario loader and homepage**
|
||||
|
||||
Use `yaml.safe_load()`, validate required fields, and render scenario cards on `/`.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `pytest tests/test_scenarios.py tests/test_project_configuration.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
## Task 3: Agent Core Mock Orchestrator
|
||||
|
||||
**Files:**
|
||||
- Create: `agent_core/results.py`
|
||||
- Create: `agent_core/orchestrator.py`
|
||||
- Create: `agent_core/structured_output.py`
|
||||
- Create: `agent_core/tool_registry.py`
|
||||
- Create: `agent_core/tools/builtin_tools.py`
|
||||
- Create: `agent_core/rag/ingest.py`
|
||||
- Create: `agent_core/rag/retriever.py`
|
||||
- Test: `tests/test_agent_core.py`
|
||||
|
||||
- [ ] **Step 1: Write failing Agent Core tests**
|
||||
|
||||
```python
|
||||
from agent_core.orchestrator import run_agent
|
||||
|
||||
|
||||
def test_run_agent_returns_structured_mock_result():
|
||||
scenario = {
|
||||
"id": "knowledge_qa",
|
||||
"name": "知识库问答助手",
|
||||
"rag": {"enabled": True, "collection": "knowledge_qa", "top_k": 3},
|
||||
"tools": ["generate_action_items"],
|
||||
"output": {"type": "general_answer"},
|
||||
}
|
||||
result = run_agent(scenario, "如何处理异常?")
|
||||
assert result.status == "success"
|
||||
assert result.answer
|
||||
assert result.structured_output["output_type"] == "general_answer"
|
||||
assert result.tool_calls[0]["tool_name"] == "generate_action_items"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_agent_core.py -q`
|
||||
Expected: FAIL because `agent_core` is missing.
|
||||
|
||||
- [ ] **Step 3: Implement deterministic mock AgentResult**
|
||||
|
||||
Return stable answer, references, tool calls, model name `mock-model`, and latency.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `pytest tests/test_agent_core.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
## Task 4: Audit Module
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/audit/models.py`
|
||||
- Create: `apps/audit/services.py`
|
||||
- Create: `apps/audit/views.py`
|
||||
- Create: `apps/audit/urls.py`
|
||||
- Create: `apps/audit/admin.py`
|
||||
- Create: `templates/audit/log_list.html`
|
||||
- Create: `templates/audit/log_detail.html`
|
||||
- Test: `tests/test_audit.py`
|
||||
|
||||
- [ ] **Step 1: Write failing audit tests**
|
||||
|
||||
```python
|
||||
from apps.audit.models import AgentAuditLog
|
||||
from apps.audit.services import create_audit_log
|
||||
from agent_core.results import AgentResult
|
||||
|
||||
|
||||
def test_create_audit_log_records_success_result(db):
|
||||
result = AgentResult(answer="回答", structured_output={"x": 1}, status="success")
|
||||
log = create_audit_log("knowledge_qa", "知识库问答助手", "问题", result)
|
||||
assert AgentAuditLog.objects.count() == 1
|
||||
assert log.final_answer == "回答"
|
||||
assert log.structured_output == {"x": 1}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_audit.py -q`
|
||||
Expected: FAIL because audit app is missing.
|
||||
|
||||
- [ ] **Step 3: Implement model, service, admin, views**
|
||||
|
||||
Use JSONField defaults and avoid storing sensitive environment values.
|
||||
|
||||
- [ ] **Step 4: Run migrations and tests**
|
||||
|
||||
Run: `python manage.py makemigrations audit && pytest tests/test_audit.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
## Task 5: Documents Module
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/documents/models.py`
|
||||
- Create: `apps/documents/services.py`
|
||||
- Create: `apps/documents/forms.py`
|
||||
- Create: `apps/documents/views.py`
|
||||
- Create: `apps/documents/urls.py`
|
||||
- Create: `apps/documents/admin.py`
|
||||
- Create: `templates/documents/document_list.html`
|
||||
- Create: `templates/documents/upload.html`
|
||||
- Test: `tests/test_documents.py`
|
||||
|
||||
- [ ] **Step 1: Write failing document tests**
|
||||
|
||||
```python
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.documents.models import UploadedDocument
|
||||
|
||||
|
||||
def test_upload_txt_document_creates_uploaded_record(client, db):
|
||||
file = SimpleUploadedFile("rules.txt", "hello".encode("utf-8"), content_type="text/plain")
|
||||
response = client.post(reverse("documents:upload"), {"scenario_id": "knowledge_qa", "file": file})
|
||||
assert response.status_code == 302
|
||||
document = UploadedDocument.objects.get()
|
||||
assert document.status == "uploaded"
|
||||
assert document.file_type == "txt"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_documents.py -q`
|
||||
Expected: FAIL because documents app is missing.
|
||||
|
||||
- [ ] **Step 3: Implement upload/list/index flow**
|
||||
|
||||
Support `.txt` and `.md`; index action calls `agent_core.rag.ingest.ingest_document()` and updates status.
|
||||
|
||||
- [ ] **Step 4: Run migrations and tests**
|
||||
|
||||
Run: `python manage.py makemigrations documents && pytest tests/test_documents.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
## Task 6: Chat Module
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/chat/forms.py`
|
||||
- Create: `apps/chat/views.py`
|
||||
- Create: `apps/chat/urls.py`
|
||||
- Create: `apps/chat/apps.py`
|
||||
- Create: `templates/chat/index.html`
|
||||
- Test: `tests/test_chat.py`
|
||||
|
||||
- [ ] **Step 1: Write failing chat tests**
|
||||
|
||||
```python
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.audit.models import AgentAuditLog
|
||||
|
||||
|
||||
def test_chat_post_returns_agent_result_and_audit_log(client, db):
|
||||
response = client.post(reverse("chat:index", args=["knowledge_qa"]), {"message": "如何处理异常?"})
|
||||
assert response.status_code == 200
|
||||
assert "mock-model" in response.content.decode("utf-8")
|
||||
assert AgentAuditLog.objects.count() == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_chat.py -q`
|
||||
Expected: FAIL because chat app is missing.
|
||||
|
||||
- [ ] **Step 3: Implement chat form and view**
|
||||
|
||||
Validate message, call `get_scenario()`, `run_agent()`, then `create_audit_log()`.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `pytest tests/test_chat.py tests/test_audit.py tests/test_agent_core.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
## Task 7: Docker and Documentation Alignment
|
||||
|
||||
**Files:**
|
||||
- Create: `.env.example`
|
||||
- Create: `Dockerfile`
|
||||
- Create: `docker-compose.yml`
|
||||
- Modify: `README.md`
|
||||
- Test: all tests and Django checks.
|
||||
|
||||
- [ ] **Step 1: Add deployment files**
|
||||
|
||||
Use a single web service, install `requirements.txt`, run migrations, and serve `0.0.0.0:8000`.
|
||||
|
||||
- [ ] **Step 2: Verify Django and tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python manage.py check
|
||||
pytest -q
|
||||
```
|
||||
|
||||
Expected: all checks pass.
|
||||
|
||||
- [ ] **Step 3: Verify docs path references**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$patterns = @('docs/需求分析', 'docs/设计文档', 'V1总需求文档', '智能体总体设计')
|
||||
Get-ChildItem -Recurse -File |
|
||||
Where-Object {
|
||||
$_.FullName -notlike '*\.git\*' -and
|
||||
$_.FullName -notlike '*\.idea\*' -and
|
||||
$_.FullName -notlike '*docs\superpowers\plans\2026-05-29-v1-django-baseline.md'
|
||||
} |
|
||||
Select-String -Pattern $patterns
|
||||
```
|
||||
|
||||
Expected: no matches.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: The plan covers Chinese docs, five scenario configs, Django startup, homepage, chat, audit, documents, Agent Core, and Docker baseline.
|
||||
- Placeholder scan: No implementation step relies on an undefined placeholder; mock LLM/RAG is intentionally scoped as the first runnable baseline.
|
||||
- Type consistency: Tests use `AgentResult`, `run_agent`, `list_scenarios`, `get_scenario`, `UploadedDocument`, and `AgentAuditLog` consistently.
|
||||
@@ -1,71 +0,0 @@
|
||||
# 模块详细设计文档索引
|
||||
|
||||
## 1. 设计文档说明
|
||||
|
||||
本目录存放 Universal Agent Demo Framework V1 的设计文档。需求文档回答“要做什么”,设计文档回答“怎么实现、边界在哪里、如何验证”。
|
||||
|
||||
文档命名统一使用中文编号,便于复试讲解和按顺序阅读。
|
||||
|
||||
## 2. 模块设计文档列表
|
||||
|
||||
| 顺序 | 文档 | 说明 |
|
||||
|---|---|---|
|
||||
| 0 | `0.设计文档索引.md` | 当前索引 |
|
||||
| 1 | `1.智能体总体设计.md` | 智能核心总体链路、配置、输出和 Adapter |
|
||||
| 2 | `2.功能流程设计.md` | 复试准备、演示、上传、入库、对话和审计流程 |
|
||||
| 3 | `3.数据库设计.md` | Django 数据模型、字段、索引和初始化策略 |
|
||||
| 4 | `4.页面与路由设计.md` | 页面结构、URL、跳转和异常状态 |
|
||||
| 5 | `5.部署设计.md` | 本地、Docker、环境变量和持久化 |
|
||||
|
||||
模块详细设计位于 `模块设计/`:
|
||||
|
||||
| 模块 | 文档 |
|
||||
|---|---|
|
||||
| 配置 | `模块设计/1.配置模块详细设计.md` |
|
||||
| 场景 | `模块设计/2.场景模块详细设计.md` |
|
||||
| 文档 | `模块设计/3.文档模块详细设计.md` |
|
||||
| 对话 | `模块设计/4.对话模块详细设计.md` |
|
||||
| 审计 | `模块设计/5.审计模块详细设计.md` |
|
||||
| 智能核心 | `模块设计/6.智能核心模块详细设计.md` |
|
||||
|
||||
## 3. 模块依赖关系
|
||||
|
||||
```text
|
||||
config
|
||||
|-- apps.scenarios
|
||||
|-- apps.documents
|
||||
|-- apps.chat
|
||||
|-- apps.audit
|
||||
|
||||
apps.scenarios
|
||||
|-- reads configs/*.yaml
|
||||
|
||||
apps.documents
|
||||
|-- depends on apps.scenarios
|
||||
|-- calls agent_core.rag.ingest
|
||||
|
||||
apps.chat
|
||||
|-- depends on apps.scenarios
|
||||
|-- calls agent_core.orchestrator
|
||||
|-- calls apps.audit.services
|
||||
|
||||
apps.audit
|
||||
|-- stores AgentResult snapshots
|
||||
|
||||
agent_core
|
||||
|-- consumes scenario config
|
||||
|-- uses RAG, tools, LLM provider and structured output parser
|
||||
```
|
||||
|
||||
## 4. 推荐阅读顺序
|
||||
|
||||
1. `docs/需求分析/1.V1总需求文档.md`
|
||||
2. `docs/需求分析/2.模块需求索引.md`
|
||||
3. `docs/设计文档/1.智能体总体设计.md`
|
||||
4. `docs/设计文档/2.功能流程设计.md`
|
||||
5. `docs/设计文档/3.数据库设计.md`
|
||||
6. `docs/设计文档/4.页面与路由设计.md`
|
||||
7. `docs/设计文档/5.部署设计.md`
|
||||
8. `docs/设计文档/模块设计/*.md`
|
||||
|
||||
后续编码时,每个模块应先对照对应需求文档和详细设计,再实现模型、服务、视图和测试。
|
||||
@@ -1,211 +0,0 @@
|
||||
# 智能体总体设计文档
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
Agent 设计的核心目标是支持未知复试题的快速适配。
|
||||
|
||||
系统不针对单一业务写死,而是通过场景配置、知识库、工具和输出模板组合出不同业务 Agent。
|
||||
|
||||
```text
|
||||
业务 Agent = 场景配置 + 知识库 + 工具集 + 输出模板 + 审计日志 + 模型适配器
|
||||
```
|
||||
|
||||
## 2. Agent 类型
|
||||
|
||||
V1 预置 5 类 Agent 场景:
|
||||
|
||||
| Agent ID | 名称 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `knowledge_qa` | 知识库问答助手 | SOP、制度、客服知识库 |
|
||||
| `document_review` | 文档审核助手 | 合同、制度、SOP、材料审核 |
|
||||
| `ticket_assistant` | 工单处理助手 | 客服、售后、运维工单 |
|
||||
| `quality_analysis` | 质量异常分析助手 | 生产、质检、缺陷分析 |
|
||||
| `risk_audit` | 风险审核助手 | 财务、采购、报销、合同风险 |
|
||||
|
||||
## 3. Agent 执行链路
|
||||
|
||||
```text
|
||||
用户输入
|
||||
↓
|
||||
加载场景配置
|
||||
↓
|
||||
判断是否启用 RAG
|
||||
↓
|
||||
检索知识库片段
|
||||
↓
|
||||
加载可用工具
|
||||
↓
|
||||
构造 Prompt
|
||||
↓
|
||||
调用大模型
|
||||
↓
|
||||
解析工具调用和结构化输出
|
||||
↓
|
||||
生成 AgentResult
|
||||
↓
|
||||
写入审计日志
|
||||
↓
|
||||
页面展示
|
||||
```
|
||||
|
||||
## 4. 场景配置结构
|
||||
|
||||
场景配置使用 YAML,V1 以配置文件作为场景唯一事实来源,后台管理不作为场景配置入口。
|
||||
|
||||
```yaml
|
||||
id: quality_analysis
|
||||
name: 质量异常分析助手
|
||||
description: 用于分析生产质量异常、检索 SOP、生成处理建议
|
||||
|
||||
agent:
|
||||
role: 质量管理专家
|
||||
goal: 根据用户问题、知识库和工具结果,输出可执行的质量分析报告
|
||||
system_prompt: ""
|
||||
instructions:
|
||||
- 回答必须基于知识库或工具结果
|
||||
- 不确定时必须说明缺失信息
|
||||
- 涉及质量风险时给出风险等级
|
||||
|
||||
rag:
|
||||
enabled: true
|
||||
collection: quality_docs
|
||||
top_k: 5
|
||||
|
||||
tools:
|
||||
- query_demo_records
|
||||
- calculate_rate
|
||||
|
||||
output:
|
||||
type: quality_report
|
||||
|
||||
audit:
|
||||
enabled: true
|
||||
log_retrieval: true
|
||||
log_tool_calls: true
|
||||
```
|
||||
|
||||
## 5. Prompt 组成
|
||||
|
||||
Prompt 建议由以下部分组成:
|
||||
|
||||
```text
|
||||
系统角色
|
||||
任务目标
|
||||
行为约束
|
||||
输出格式要求
|
||||
知识库检索内容
|
||||
工具调用结果
|
||||
用户问题
|
||||
```
|
||||
|
||||
V1 不追求复杂 Prompt 框架,优先保证可读、可改、可解释。
|
||||
|
||||
## 6. RAG 策略
|
||||
|
||||
RAG 在 V1 中负责给 Agent 提供题目材料和业务知识。
|
||||
|
||||
入库流程:
|
||||
|
||||
```text
|
||||
上传文件
|
||||
↓
|
||||
抽取文本
|
||||
↓
|
||||
文本切分
|
||||
↓
|
||||
生成 embedding
|
||||
↓
|
||||
写入 Chroma
|
||||
```
|
||||
|
||||
检索流程:
|
||||
|
||||
```text
|
||||
用户问题
|
||||
↓
|
||||
按 scenario_id 和可选 document_ids 过滤
|
||||
↓
|
||||
向量检索 top_k
|
||||
↓
|
||||
返回片段内容、来源和分数
|
||||
```
|
||||
|
||||
## 7. 工具调用策略
|
||||
|
||||
工具用于补足大模型不能直接可靠完成的业务动作。
|
||||
|
||||
V1 内置工具:
|
||||
|
||||
| 工具 | 用途 |
|
||||
|---|---|
|
||||
| `calculate_rate` | 计算比例、缺陷率、通过率 |
|
||||
| `query_demo_records` | 查询模拟业务数据 |
|
||||
| `check_required_fields` | 检查必填项 |
|
||||
| `generate_action_items` | 生成行动项 |
|
||||
|
||||
工具返回格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "calculate_rate",
|
||||
"success": true,
|
||||
"arguments": {},
|
||||
"result": {},
|
||||
"error": ""
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 结构化输出
|
||||
|
||||
V1 支持以下输出类型:
|
||||
|
||||
- `general_answer`
|
||||
- `document_review_report`
|
||||
- `ticket_response`
|
||||
- `quality_report`
|
||||
- `risk_audit_report`
|
||||
|
||||
结构化输出优先使用 JSON。
|
||||
|
||||
解析失败时:
|
||||
|
||||
- 保留模型原始输出。
|
||||
- 返回解析错误。
|
||||
- 页面展示原始回答。
|
||||
- 审计日志记录失败原因。
|
||||
|
||||
## 9. AgentResult
|
||||
|
||||
Agent Core 统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"answer": "",
|
||||
"structured_output": {},
|
||||
"references": [],
|
||||
"tool_calls": [],
|
||||
"raw_output": "",
|
||||
"model_name": "",
|
||||
"latency_ms": 0,
|
||||
"status": "success",
|
||||
"error": ""
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Adapter 策略
|
||||
|
||||
V1 默认使用自研轻量 Orchestrator,通过 OpenAI 兼容接口接入 LLM 与 Embedding,可自主选择 OpenAI、硅基流动等兼容服务。
|
||||
|
||||
后续可以扩展:
|
||||
|
||||
- OpenAI Agents SDK Adapter。
|
||||
- Dify API Adapter。
|
||||
- LangGraph Adapter。
|
||||
|
||||
所有 Adapter 应保持统一接口:
|
||||
|
||||
```text
|
||||
run_agent(scenario_config, user_input, options=None) -> AgentResult
|
||||
```
|
||||
|
||||
这样可以保证 Django 业务层不受底层 Agent 编排实现影响。
|
||||
@@ -1,169 +0,0 @@
|
||||
# V1 功能设计文档
|
||||
|
||||
## 1. 功能设计目标
|
||||
|
||||
V1 的功能设计目标是让复试展示者在本地快速完成一个可讲解、可演示、可改题的 Agent Demo。系统不追求复杂平台能力,而是优先保证以下闭环稳定:
|
||||
|
||||
- 场景配置可选择。
|
||||
- 文档可上传并入库。
|
||||
- 用户可在场景下发起对话。
|
||||
- Agent 可返回结构化结果、引用来源和工具调用记录。
|
||||
- 每次成功或失败的对话都有审计记录。
|
||||
- 本地和 Docker 均可启动。
|
||||
|
||||
## 2. 用户角色
|
||||
|
||||
V1 仅设计一个用户角色:Demo 操作者。
|
||||
|
||||
该角色负责启动系统、选择场景、上传材料、触发入库、发起对话、查看输出和审计日志。系统不在 V1 中区分管理员、审核员、普通用户等权限角色。
|
||||
|
||||
## 3. 核心业务流程
|
||||
|
||||
```text
|
||||
启动系统
|
||||
↓
|
||||
查看 5 个预置场景
|
||||
↓
|
||||
选择场景
|
||||
↓
|
||||
上传题目材料
|
||||
↓
|
||||
触发知识库入库
|
||||
↓
|
||||
发起 Agent 对话
|
||||
↓
|
||||
查看结构化输出、引用和工具调用
|
||||
↓
|
||||
查看审计日志
|
||||
```
|
||||
|
||||
任一环节失败时,页面应给出明确提示,并尽量保留用户已完成的上下文。
|
||||
|
||||
## 4. 场景选择流程
|
||||
|
||||
1. 首页调用 `apps.scenarios.services.list_scenarios()`。
|
||||
2. 服务从 `configs/` 读取 YAML 场景配置。
|
||||
3. 校验必填字段、工具名称和输出类型。
|
||||
4. 页面展示场景名称、描述、适用题型、启用状态。
|
||||
5. 用户点击进入 `/chat/<scenario_id>/`。
|
||||
|
||||
异常处理:
|
||||
|
||||
- 配置目录不存在:展示空状态和配置目录提示。
|
||||
- 单个配置非法:不阻断其他配置,页面展示该配置错误。
|
||||
- 场景不存在:跳转或渲染错误页,提示检查场景 ID。
|
||||
|
||||
## 5. 文件上传流程
|
||||
|
||||
1. 用户进入 `/documents/upload/`。
|
||||
2. 页面加载可用场景下拉框。
|
||||
3. 用户选择场景并上传 `.txt`、`.md`、`.pdf` 或 `.docx` 文件。
|
||||
4. Documents 模块校验文件类型和大小。
|
||||
5. 保存文件到 `UPLOAD_ROOT/<scenario_id>/`。
|
||||
6. 写入 `UploadedDocument` 记录,状态为 `uploaded`。
|
||||
7. 返回文件列表页并展示上传结果。
|
||||
|
||||
V1 文件上传默认手动入库,避免上传大文件时页面阻塞过久。
|
||||
|
||||
## 6. 文档入库流程
|
||||
|
||||
1. 用户在文件列表点击“入库”。
|
||||
2. Documents 模块读取文件并抽取文本。
|
||||
3. 调用 `agent_core.rag.ingest.ingest_document()`。
|
||||
4. Agent Core 按固定长度切分文本。
|
||||
5. 写入本地 Chroma collection。
|
||||
6. 入库成功:更新状态为 `indexed`。
|
||||
7. 入库失败:更新状态为 `failed`,保存错误信息。
|
||||
|
||||
文本为空、文件丢失、向量库不可写都应进入失败状态,不能让页面报 500。
|
||||
|
||||
## 7. Agent 对话流程
|
||||
|
||||
```text
|
||||
用户提交问题
|
||||
↓
|
||||
Chat 表单校验
|
||||
↓
|
||||
Scenarios 加载场景配置
|
||||
↓
|
||||
Agent Core 执行 run_agent()
|
||||
↓
|
||||
RAG 按场景和可选文档范围检索知识片段
|
||||
↓
|
||||
工具系统执行可用工具
|
||||
↓
|
||||
LLM Provider 生成结果
|
||||
↓
|
||||
结构化输出解析
|
||||
↓
|
||||
Audit 写入日志
|
||||
↓
|
||||
Chat 页面展示结果
|
||||
```
|
||||
|
||||
Chat 模块只负责请求处理和页面展示,不直接写 RAG、工具和模型调用细节。
|
||||
|
||||
## 8. RAG 检索流程
|
||||
|
||||
1. Orchestrator 读取场景配置中的 `rag.enabled`、`collection`、`top_k`。
|
||||
2. 若启用 RAG,则调用 `agent_core.rag.retriever.retrieve()`。
|
||||
3. 检索必须按 `scenario_id` 过滤,避免跨场景污染。
|
||||
4. 如果用户在对话页选择了文档,则同时按 `document_ids` 过滤;未选择时使用当前场景全部已入库文档。
|
||||
5. 返回片段内容、来源文件、chunk ID、分数。
|
||||
6. 片段进入 Prompt,同时随 AgentResult 返回给页面和审计日志。
|
||||
|
||||
检索失败时,AgentResult 应记录错误或警告;若业务允许,可继续使用非 RAG 上下文回答。
|
||||
|
||||
## 9. 工具调用流程
|
||||
|
||||
1. 场景配置声明可用工具名称。
|
||||
2. Orchestrator 从 Tool Registry 查询工具。
|
||||
3. 对不可用工具记录失败,不中断整个流程。
|
||||
4. 内置工具按统一参数和返回结构执行。
|
||||
5. 工具结果进入 Prompt 或结构化输出上下文。
|
||||
6. 所有工具调用写入 AgentResult 和审计日志。
|
||||
|
||||
V1 先采用“配置声明 + Orchestrator 决策”的轻量策略,不实现复杂多轮工具调用协议。
|
||||
|
||||
## 10. 审计日志流程
|
||||
|
||||
1. Chat 模块在 Agent Core 返回后调用 `apps.audit.services.create_audit_log()`。
|
||||
2. 成功结果记录输入、输出、引用、工具调用、模型名和耗时。
|
||||
3. 失败结果也记录场景、输入、错误信息和已产生的中间结果。
|
||||
4. 日志中不得保存 `LLM_API_KEY`、环境变量完整内容或上传文件绝对敏感路径。
|
||||
5. 审计列表展示摘要,详情页展示完整 JSON 片段。
|
||||
|
||||
## 11. 复试改题流程
|
||||
|
||||
1. 判断题目最接近的模板。
|
||||
2. 复制 `configs/` 中相近 YAML。
|
||||
3. 修改场景名称、角色、目标、指令和输出类型。
|
||||
4. 上传题目文档并入库。
|
||||
5. 如题目需要计算或查询,新增一个内置工具并在场景中声明。
|
||||
6. 用 2 到 3 个问题验证输出和审计链路。
|
||||
7. 演示时重点展示配置、知识库、工具调用、结构化结果和审计日志。
|
||||
|
||||
## 12. 异常处理流程
|
||||
|
||||
| 异常 | 处理方式 |
|
||||
|---|---|
|
||||
| 场景配置缺失 | 页面展示错误,保留返回首页入口 |
|
||||
| 场景字段非法 | 标记非法配置,不影响其他场景 |
|
||||
| 上传文件类型不支持 | 表单错误提示 |
|
||||
| 文件读取失败 | 文档状态改为 `failed` |
|
||||
| RAG 入库失败 | 记录错误信息并允许重试 |
|
||||
| LLM 配置缺失 | AgentResult 返回失败,审计日志记录失败 |
|
||||
| 工具调用失败 | 记录工具失败,流程尽量继续 |
|
||||
| 结构化解析失败 | 展示原始输出并记录解析错误 |
|
||||
|
||||
## 13. V1 功能验收标准
|
||||
|
||||
- 首页可以展示 5 个预置场景。
|
||||
- 场景配置来自 YAML 文件。
|
||||
- 可以上传 `.txt`、`.md`、`.pdf` 和 `.docx` 文件。
|
||||
- 文件可触发入库,并显示 `uploaded`、`indexed`、`failed` 状态。
|
||||
- 可以进入任一场景对话页并提交问题。
|
||||
- AgentResult 至少包含回答、结构化输出、引用、工具调用、耗时和状态。
|
||||
- 成功和失败对话都能生成审计日志。
|
||||
- 审计详情可以解释一次 Agent 输出的输入、依据和过程。
|
||||
- 本地启动和 Docker 启动路径清晰可执行。
|
||||
@@ -1,144 +0,0 @@
|
||||
# V1 数据库设计文档
|
||||
|
||||
## 1. 数据库设计目标
|
||||
|
||||
V1 数据库设计优先服务本地演示、讲解清晰和快速改题。数据模型只覆盖文件、对话、审计和简单示例业务数据,不引入复杂权限、多租户或工作流状态机。
|
||||
|
||||
## 2. 数据库选型
|
||||
|
||||
默认使用 SQLite,数据库文件位于 `data/db.sqlite3`。SQLite 适合复试现场单机运行,便于 Docker 挂载和备份。
|
||||
|
||||
后续如需多人协作或更正式部署,可通过 Django settings 切换到 PostgreSQL,但 V1 不强制实现。
|
||||
|
||||
## 3. 表结构总览
|
||||
|
||||
| 表 | Django Model | 模块 | 说明 |
|
||||
|---|---|---|---|
|
||||
| uploaded_document | `UploadedDocument` | Documents | 上传文件元数据和入库状态 |
|
||||
| agent_audit_log | `AgentAuditLog` | Audit | Agent 执行审计快照 |
|
||||
| demo_business_record | `DemoBusinessRecord` | Agent Core / Tools | 内置工具可查询的模拟业务数据 |
|
||||
| chat_session | `ChatSession` | Chat | 可选,对话会话 |
|
||||
| chat_message | `ChatMessage` | Chat | 可选,对话消息 |
|
||||
|
||||
## 4. UploadedDocument 表设计
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | 主键 |
|
||||
| scenario_id | CharField(100) | indexed | 关联场景 ID |
|
||||
| original_name | CharField(255) | required | 原始文件名 |
|
||||
| file | FileField | required | 文件相对路径 |
|
||||
| file_type | CharField(20) | required | `txt`、`md`、`pdf`、`docx` 等 |
|
||||
| size | PositiveIntegerField | default 0 | 字节数 |
|
||||
| status | CharField(20) | indexed | `uploaded`、`indexed`、`failed` |
|
||||
| error_message | TextField | blank | 入库失败原因 |
|
||||
| created_at | DateTimeField | auto_now_add | 上传时间 |
|
||||
| updated_at | DateTimeField | auto_now | 更新时间 |
|
||||
|
||||
状态流转:
|
||||
|
||||
```text
|
||||
uploaded -> indexed
|
||||
uploaded -> failed
|
||||
failed -> indexed
|
||||
failed -> failed
|
||||
```
|
||||
|
||||
重新入库时应按文档维度覆盖或清理旧 chunk,避免同一文件重复出现在向量检索结果中。文档选择范围由 Chat 表单本次提交的 `document_ids` 传入 Agent Core,V1 不需要为该选择单独建表。
|
||||
|
||||
## 5. AgentAuditLog 表设计
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | 主键 |
|
||||
| scenario_id | CharField(100) | indexed | 场景 ID |
|
||||
| scenario_name | CharField(200) | blank | 场景名称快照 |
|
||||
| user_input | TextField | required | 用户输入 |
|
||||
| retrieved_chunks | JSONField | default list | RAG 引用片段 |
|
||||
| tool_calls | JSONField | default list | 工具调用记录 |
|
||||
| structured_output | JSONField | default dict | 结构化输出 |
|
||||
| final_answer | TextField | blank | 最终回答 |
|
||||
| raw_output | TextField | blank | 模型原始输出 |
|
||||
| model_name | CharField(100) | blank | 模型名称 |
|
||||
| latency_ms | PositiveIntegerField | default 0 | 执行耗时 |
|
||||
| status | CharField(20) | indexed | `success`、`failed` |
|
||||
| error_message | TextField | blank | 错误信息 |
|
||||
| created_at | DateTimeField | auto_now_add, indexed | 创建时间 |
|
||||
|
||||
审计日志保存的是执行快照,不依赖场景配置后续是否被修改。
|
||||
|
||||
## 6. DemoBusinessRecord 表设计
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|---|---|---|---|
|
||||
| id | BigAutoField | PK | 主键 |
|
||||
| scenario_id | CharField(100) | indexed | 适用场景 |
|
||||
| record_type | CharField(100) | indexed | 记录类型,如 defect、ticket、invoice |
|
||||
| title | CharField(255) | required | 标题 |
|
||||
| payload | JSONField | default dict | 模拟业务数据 |
|
||||
| created_at | DateTimeField | auto_now_add | 创建时间 |
|
||||
|
||||
该表为 V1 必需表,用于 `query_demo_records` 工具,避免工具只能返回硬编码数据。Django Admin 可以管理该表的数据,场景 YAML 仍不在 Admin 中编辑。
|
||||
|
||||
## 7. ChatSession 表设计
|
||||
|
||||
V1 可先不实现会话持久化。如果实现,字段建议如下:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | BigAutoField | 主键 |
|
||||
| scenario_id | CharField(100) | 场景 ID |
|
||||
| title | CharField(255) | 会话标题 |
|
||||
| created_at | DateTimeField | 创建时间 |
|
||||
| updated_at | DateTimeField | 更新时间 |
|
||||
|
||||
## 8. ChatMessage 表设计
|
||||
|
||||
V1 可通过审计日志满足演示追踪,不强制实现消息表。如果实现,字段建议如下:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | BigAutoField | 主键 |
|
||||
| session | ForeignKey(ChatSession) | 所属会话 |
|
||||
| role | CharField(20) | `user`、`assistant`、`system` |
|
||||
| content | TextField | 消息内容 |
|
||||
| audit_log | ForeignKey(AgentAuditLog, null=True) | 关联审计 |
|
||||
| created_at | DateTimeField | 创建时间 |
|
||||
|
||||
## 9. 表关系设计
|
||||
|
||||
```text
|
||||
Scenario YAML
|
||||
|-- scenario_id
|
||||
|-- UploadedDocument.scenario_id
|
||||
|-- AgentAuditLog.scenario_id
|
||||
|-- DemoBusinessRecord.scenario_id
|
||||
|-- ChatSession.scenario_id
|
||||
|
||||
ChatSession 1 -- N ChatMessage
|
||||
ChatMessage 0/1 -- 1 AgentAuditLog
|
||||
```
|
||||
|
||||
场景配置 V1 存在 YAML 中,不建 `Scenario` 数据表。这样更方便复试现场复制和修改配置文件。
|
||||
|
||||
## 10. 索引设计
|
||||
|
||||
- `UploadedDocument(scenario_id, status)`:用于按场景查看文件和入库状态。
|
||||
- `AgentAuditLog(scenario_id, created_at)`:用于按场景查看最近日志。
|
||||
- `AgentAuditLog(status, created_at)`:用于排查失败日志。
|
||||
- `DemoBusinessRecord(scenario_id, record_type)`:用于工具查询模拟数据。
|
||||
|
||||
## 11. 数据初始化策略
|
||||
|
||||
- 场景初始化:读取 `configs/*.yaml`,不写数据库。
|
||||
- 示例业务数据:可提供 Django management command 初始化 `DemoBusinessRecord`。
|
||||
- 超级用户:本地演示可手动创建,Docker 可通过说明引导创建。
|
||||
- 上传文件和 Chroma 数据:存放在 `data/` 下,通过 Docker volume 持久化。
|
||||
|
||||
## 12. 后续扩展方向
|
||||
|
||||
- 增加 `Scenario` 表,实现后台编辑场景。
|
||||
- 增加 `ToolCallLog` 独立表,用于复杂工具审计。
|
||||
- 使用 PostgreSQL JSONB 优化 JSON 查询。
|
||||
- 增加用户和权限模型。
|
||||
- 增加文档 chunk 元数据表,便于从数据库追踪向量库内容。
|
||||
@@ -1,179 +0,0 @@
|
||||
# V1 页面与路由设计文档
|
||||
|
||||
## 1. 页面设计目标
|
||||
|
||||
V1 页面使用 Django Templates,优先保证清晰、稳定、可讲解。页面应围绕复试演示的主路径组织:选择场景、上传文档、入库、对话、查看审计。
|
||||
|
||||
## 2. 页面列表
|
||||
|
||||
| 页面 | 路径 | 模块 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 首页/场景列表 | `/` | Scenarios | 展示 5 个预置场景 |
|
||||
| Agent 对话页 | `/chat/<scenario_id>/` | Chat | 提交问题并展示结果 |
|
||||
| 文件列表页 | `/documents/` | Documents | 查看上传文件和入库状态 |
|
||||
| 文件上传页 | `/documents/upload/` | Documents | 上传题目材料 |
|
||||
| 文档入库动作 | `/documents/<id>/index/` | Documents | POST 触发入库 |
|
||||
| 审计日志列表 | `/audit/` | Audit | 查看对话记录 |
|
||||
| 审计日志详情 | `/audit/<log_id>/` | Audit | 查看单次执行详情 |
|
||||
| Django Admin | `/admin/` | Config | 后台管理 |
|
||||
|
||||
## 3. 路由总览
|
||||
|
||||
```text
|
||||
config.urls
|
||||
|-- "" -> apps.scenarios.urls
|
||||
|-- "chat/" -> apps.chat.urls
|
||||
|-- "documents/" -> apps.documents.urls
|
||||
|-- "audit/" -> apps.audit.urls
|
||||
|-- "admin/" -> django.contrib.admin
|
||||
```
|
||||
|
||||
各模块只暴露自己的 URL,避免把业务路由集中写在 `config.urls` 中。
|
||||
|
||||
## 4. 首页与场景列表页
|
||||
|
||||
路径:`/`
|
||||
|
||||
展示内容:
|
||||
|
||||
- 系统名称和简短定位。
|
||||
- 5 个场景卡片或列表。
|
||||
- 场景名称、描述、适用题型、启用状态。
|
||||
- “进入对话”按钮。
|
||||
- 文件管理和审计日志入口。
|
||||
|
||||
错误状态:
|
||||
|
||||
- 没有可用场景:展示配置目录提示。
|
||||
- 配置读取失败:展示失败原因和文件名。
|
||||
|
||||
## 5. Agent 对话页
|
||||
|
||||
路径:`/chat/<scenario_id>/`
|
||||
|
||||
页面区域:
|
||||
|
||||
- 场景摘要:名称、角色、目标、RAG 状态、工具列表。
|
||||
- 文档范围:当前场景下状态为 `indexed` 的文档多选框;未选择时默认使用全部已入库文档。
|
||||
- 输入区:一个 textarea 和提交按钮。
|
||||
- 结果区:自然语言回答和结构化输出。
|
||||
- 引用区:source、chunk_id、score、content。
|
||||
- 工具区:tool_name、success、arguments、result、error。
|
||||
- 审计入口:当前对话生成日志后展示详情链接。
|
||||
|
||||
POST 成功后仍渲染同一页面,保留用户问题和 AgentResult。
|
||||
|
||||
## 6. 文件上传页
|
||||
|
||||
路径:`/documents/upload/`
|
||||
|
||||
页面元素:
|
||||
|
||||
- 场景选择下拉框。
|
||||
- 文件选择控件。
|
||||
- 支持类型提示。
|
||||
- 上传按钮。
|
||||
- 错误或成功提示。
|
||||
|
||||
表单接受 `.txt`、`.md`、`.pdf`、`.docx`。PDF 仅要求纯文本抽取,DOCX 仅要求段落和普通文本抽取。
|
||||
|
||||
## 7. 文件列表页
|
||||
|
||||
路径:`/documents/`
|
||||
|
||||
展示字段:
|
||||
|
||||
- 原始文件名。
|
||||
- 所属场景。
|
||||
- 文件类型。
|
||||
- 文件大小。
|
||||
- 入库状态。
|
||||
- 上传时间。
|
||||
- 入库按钮。
|
||||
- 失败原因。
|
||||
|
||||
状态为 `indexed` 时可以显示“重新入库”,重新入库需要覆盖或清理该文档旧 chunk。
|
||||
|
||||
## 8. 审计日志列表页
|
||||
|
||||
路径:`/audit/`
|
||||
|
||||
展示字段:
|
||||
|
||||
- 日志 ID。
|
||||
- 场景名称。
|
||||
- 用户输入摘要。
|
||||
- 状态。
|
||||
- 模型名称。
|
||||
- 执行耗时。
|
||||
- 创建时间。
|
||||
- 详情入口。
|
||||
|
||||
默认按 `created_at desc` 排序。
|
||||
|
||||
## 9. 审计日志详情页
|
||||
|
||||
路径:`/audit/<log_id>/`
|
||||
|
||||
展示内容:
|
||||
|
||||
- 场景信息。
|
||||
- 用户输入。
|
||||
- 最终回答。
|
||||
- 结构化输出 JSON。
|
||||
- RAG 引用列表。
|
||||
- 工具调用列表。
|
||||
- 模型名称和耗时。
|
||||
- 错误信息。
|
||||
|
||||
JSON 内容可以先用 `<pre>` 展示,优先保证可读。
|
||||
|
||||
## 10. Django Admin 页面
|
||||
|
||||
Admin 注册:
|
||||
|
||||
- `UploadedDocument`
|
||||
- `AgentAuditLog`
|
||||
- `DemoBusinessRecord`
|
||||
|
||||
V1 不要求在 Admin 中编辑 YAML 场景,场景仍以配置文件为准。
|
||||
|
||||
## 11. 页面跳转关系
|
||||
|
||||
```text
|
||||
首页
|
||||
|-- 进入对话页
|
||||
|-- 文件列表页
|
||||
|-- 审计日志列表页
|
||||
|
||||
文件列表页
|
||||
|-- 文件上传页
|
||||
|-- 触发入库后回到文件列表页
|
||||
|
||||
对话页
|
||||
|-- 提交后留在当前对话页
|
||||
|-- 查看当前审计详情
|
||||
|
||||
审计列表页
|
||||
|-- 审计详情页
|
||||
```
|
||||
|
||||
## 12. 页面异常状态
|
||||
|
||||
| 页面 | 异常 | 展示方式 |
|
||||
|---|---|---|
|
||||
| 首页 | 场景配置为空 | 空状态和配置目录说明 |
|
||||
| 对话页 | 场景不存在 | 明确提示并提供返回首页 |
|
||||
| 对话页 | Agent 执行失败 | 展示错误、保留输入、写入失败审计 |
|
||||
| 上传页 | 文件类型错误 | 表单错误 |
|
||||
| 文件列表 | 入库失败 | 状态为 failed 并显示原因 |
|
||||
| 审计详情 | 日志不存在 | 404 或友好错误页 |
|
||||
|
||||
## 13. V1 页面验收标准
|
||||
|
||||
- 主要页面可通过浏览器访问。
|
||||
- 页面之间跳转路径完整。
|
||||
- POST 表单使用 CSRF 保护。
|
||||
- 所有用户可见错误都有中文提示。
|
||||
- Agent 对话结果可以同时看到回答、引用、工具和审计入口。
|
||||
- 页面不依赖 React/Vue。
|
||||
@@ -1,111 +0,0 @@
|
||||
# V1 部署设计文档
|
||||
|
||||
## 1. 部署设计目标
|
||||
|
||||
V1 部署目标是降低复试现场环境风险。系统应支持本地 Python 方式启动,也支持 Docker Compose 一键启动。默认不依赖外部数据库、Redis 或任务队列。
|
||||
|
||||
## 2. 本地运行方式
|
||||
|
||||
建议命令:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
本地运行使用 SQLite、`data/uploads` 和 `data/chroma`。
|
||||
|
||||
当前本地方式会在启动时自动读取根目录 `.env`,因此 `runserver`、`pytest` 和日常脚本可以共享同一套配置。
|
||||
|
||||
## 3. Docker 运行方式
|
||||
|
||||
建议命令:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
V1 Docker Compose 只需要一个 Django Web 服务。Chroma 使用本地持久化目录,不额外启动独立服务。
|
||||
|
||||
## 4. 环境变量设计
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `DJANGO_SECRET_KEY` | `dev-secret-key` | 开发密钥 |
|
||||
| `DJANGO_DEBUG` | `true` | 是否开启调试 |
|
||||
| `DJANGO_ALLOWED_HOSTS` | `*` | 允许主机 |
|
||||
| `LLM_API_KEY` | 空 | 大模型 API Key |
|
||||
| `LLM_BASE_URL` | `https://api.openai.com/v1` | OpenAI 兼容接口地址,可接入 OpenAI、硅基流动等兼容服务 |
|
||||
| `LLM_MODEL` | `gpt-4.1-mini` | 默认模型 |
|
||||
| `EMBEDDING_API_KEY` | 空 | Embedding API Key;为空时可复用 `LLM_API_KEY` |
|
||||
| `EMBEDDING_BASE_URL` | 空 | Embedding OpenAI 兼容接口地址;为空时可复用 `LLM_BASE_URL` |
|
||||
| `EMBEDDING_MODEL` | `text-embedding-3-small` | 默认 Embedding 模型 |
|
||||
| `SCENARIO_CONFIG_DIR` | `configs` | 场景配置目录 |
|
||||
| `UPLOAD_ROOT` | `data/uploads` | 上传目录 |
|
||||
| `CHROMA_PATH` | `data/chroma` | 向量库目录 |
|
||||
|
||||
`.env.example` 应提供这些变量的样例,不写真实密钥。
|
||||
|
||||
当前实现说明:
|
||||
|
||||
- 本地 Python 方式启动时,会先加载根目录 `.env`,再读取进程环境中的覆盖值。
|
||||
- Docker Compose 方式可通过 `env_file` 向容器注入环境变量;当前仓库默认读取 `.env`。
|
||||
- 因此本地运行和容器运行可以默认共用一份 `.env`,但演示前仍应确认密钥和模型参数是否正确。
|
||||
|
||||
## 5. 目录挂载设计
|
||||
|
||||
Docker 需要持久化以下目录:
|
||||
|
||||
```text
|
||||
./data/db.sqlite3
|
||||
./data/uploads
|
||||
./data/chroma
|
||||
./configs
|
||||
```
|
||||
|
||||
`configs` 挂载后可以在不重建镜像的情况下修改场景配置。
|
||||
|
||||
## 6. SQLite 数据持久化
|
||||
|
||||
SQLite 文件放在 `data/db.sqlite3`。Docker 中应将 `data/` 作为 volume 挂载,避免容器重建后数据丢失。
|
||||
|
||||
## 7. Chroma 数据持久化
|
||||
|
||||
Chroma 数据放在 `data/chroma`。RAG 入库后,重启容器不应丢失向量数据。
|
||||
|
||||
## 8. 上传文件持久化
|
||||
|
||||
上传文件放在 `data/uploads/<scenario_id>/`。数据库只保存相对路径或 Django FileField 路径。
|
||||
|
||||
## 9. 启动命令设计
|
||||
|
||||
Docker 容器启动时建议执行:
|
||||
|
||||
```bash
|
||||
python manage.py migrate
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
V1 可以先用开发服务器满足演示。后续正式部署可切换到 Gunicorn。
|
||||
|
||||
## 10. 常见部署问题
|
||||
|
||||
| 问题 | 处理 |
|
||||
|---|---|
|
||||
| 端口 8000 被占用 | 修改 compose 端口映射 |
|
||||
| API Key 缺失 | 页面提示 LLM 或 Embedding 配置缺失 |
|
||||
| Chroma 目录无权限 | 检查 `data/chroma` 挂载权限 |
|
||||
| 上传目录不存在 | settings 或启动脚本创建目录 |
|
||||
| 场景配置读取失败 | 检查 `configs/*.yaml` 格式 |
|
||||
| Docker 构建慢 | 提前构建镜像或使用本地 Python 方式演示 |
|
||||
|
||||
## 11. 后续部署扩展
|
||||
|
||||
- 使用 Gunicorn + WhiteNoise。
|
||||
- 增加 PostgreSQL 服务。
|
||||
- 增加 Redis 和 Celery 做异步入库。
|
||||
- 增加 Nginx 反向代理。
|
||||
- 增加健康检查接口。
|
||||
@@ -1,112 +0,0 @@
|
||||
# 配置模块详细设计
|
||||
|
||||
## 1. 模块目标
|
||||
|
||||
Config 模块负责 Django 项目的启动配置和总装配。它不承载业务逻辑,只为其他模块提供稳定运行环境。
|
||||
|
||||
目标:
|
||||
|
||||
- 项目本地和 Docker 均可启动。
|
||||
- 环境变量可覆盖关键配置。
|
||||
- App、模板、静态资源、上传文件和数据库路径统一配置。
|
||||
- URL 总入口清晰,模块路由各自维护。
|
||||
|
||||
## 2. 职责边界
|
||||
|
||||
负责:
|
||||
|
||||
- `settings.py`、`urls.py`、`wsgi.py`、`asgi.py`。
|
||||
- 环境变量读取和默认值。
|
||||
- SQLite、静态文件、媒体文件、Chroma、场景配置目录。
|
||||
- Django Admin 和模块 URL 装配。
|
||||
|
||||
不负责:
|
||||
|
||||
- 不读取场景 YAML 业务内容。
|
||||
- 不调用 Agent Core。
|
||||
- 不处理上传文件文本抽取。
|
||||
- 不写审计日志。
|
||||
|
||||
## 3. 配置项设计
|
||||
|
||||
| 配置 | Django setting | 默认值 |
|
||||
|---|---|---|
|
||||
| `DJANGO_SECRET_KEY` | `SECRET_KEY` | `dev-secret-key` |
|
||||
| `DJANGO_DEBUG` | `DEBUG` | `true` |
|
||||
| `DJANGO_ALLOWED_HOSTS` | `ALLOWED_HOSTS` | `["*"]` |
|
||||
| `UPLOAD_ROOT` | `MEDIA_ROOT` | `BASE_DIR / "data" / "uploads"` |
|
||||
| `SCENARIO_CONFIG_DIR` | `SCENARIO_CONFIG_DIR` | `BASE_DIR / "configs"` |
|
||||
| `CHROMA_PATH` | `CHROMA_PATH` | `BASE_DIR / "data" / "chroma"` |
|
||||
| `LLM_API_KEY` | `LLM_API_KEY` | 空 |
|
||||
| `LLM_BASE_URL` | `LLM_BASE_URL` | `https://api.openai.com/v1` |
|
||||
| `LLM_MODEL` | `LLM_MODEL` | `gpt-4.1-mini` |
|
||||
| `EMBEDDING_API_KEY` | `EMBEDDING_API_KEY` | 空,默认可复用 `LLM_API_KEY` |
|
||||
| `EMBEDDING_BASE_URL` | `EMBEDDING_BASE_URL` | 空,默认可复用 `LLM_BASE_URL` |
|
||||
| `EMBEDDING_MODEL` | `EMBEDDING_MODEL` | `text-embedding-3-small` |
|
||||
|
||||
## 4. 目录路径设计
|
||||
|
||||
启动前或初始化时应确保:
|
||||
|
||||
```text
|
||||
data/
|
||||
uploads/
|
||||
chroma/
|
||||
configs/
|
||||
static/
|
||||
templates/
|
||||
```
|
||||
|
||||
V1 可以在 `settings.py` 中定义路径,在 management command 或启动脚本中创建目录。生产代码不应在每次请求中反复创建目录。
|
||||
|
||||
## 5. URL 总路由设计
|
||||
|
||||
`config.urls`:
|
||||
|
||||
```python
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include("apps.scenarios.urls")),
|
||||
path("chat/", include("apps.chat.urls")),
|
||||
path("documents/", include("apps.documents.urls")),
|
||||
path("audit/", include("apps.audit.urls")),
|
||||
]
|
||||
```
|
||||
|
||||
开发模式下追加 `static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)`,用于访问上传文件。
|
||||
|
||||
## 6. 静态资源与上传文件设计
|
||||
|
||||
- `STATIC_URL = "static/"`
|
||||
- `STATICFILES_DIRS = [BASE_DIR / "static"]`
|
||||
- `MEDIA_URL = "media/"`
|
||||
- `MEDIA_ROOT = UPLOAD_ROOT`
|
||||
|
||||
上传文件路径由 Documents 模块按场景组织,Config 只提供根目录。
|
||||
|
||||
## 7. 环境变量读取设计
|
||||
|
||||
V1 可使用标准库 `os.environ.get()`,不强制引入复杂配置库。
|
||||
|
||||
布尔值规则:
|
||||
|
||||
```text
|
||||
"1", "true", "yes", "on" -> True
|
||||
其他 -> False
|
||||
```
|
||||
|
||||
`DJANGO_ALLOWED_HOSTS` 使用逗号分隔,空值时默认 `["*"]`。
|
||||
|
||||
当前实现约束:
|
||||
|
||||
- 本地直接运行 Django 命令时,会先尝试解析根目录 `.env` 文件,再读取进程环境中的覆盖值。
|
||||
- Docker Compose 方式可以通过 `env_file` 传入同一批变量;当前仓库默认读取 `.env`。
|
||||
- `.env.example` 只保留占位符示例,不保存真实 API Key。
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- `python manage.py check` 通过。
|
||||
- `python manage.py migrate` 可执行。
|
||||
- `/`、`/admin/` 路由可访问。
|
||||
- `MEDIA_ROOT`、`CHROMA_PATH`、`SCENARIO_CONFIG_DIR` 在 settings 中可被其他模块引用。
|
||||
- LLM 与 Embedding 配置只从 settings 或环境变量读取,不散落在业务代码中。
|
||||
@@ -1,134 +0,0 @@
|
||||
# 场景模块详细设计
|
||||
|
||||
## 1. 模块目标
|
||||
|
||||
Scenarios 模块是业务 Agent 的入口,负责读取和展示场景配置,并向 Chat、Documents、Agent Core 提供场景上下文。
|
||||
|
||||
## 2. 职责边界
|
||||
|
||||
负责:
|
||||
|
||||
- 从 `configs/*.yaml` 读取场景。
|
||||
- 校验场景必填字段。
|
||||
- 展示场景列表和场景摘要。
|
||||
- 提供 `list_scenarios()`、`get_scenario()` 等服务。
|
||||
|
||||
不负责:
|
||||
|
||||
- 不执行 Agent。
|
||||
- 不做 RAG 检索。
|
||||
- 不调用工具和大模型。
|
||||
- 不保存审计日志。
|
||||
|
||||
## 3. 场景配置结构
|
||||
|
||||
必填结构:
|
||||
|
||||
```yaml
|
||||
id: knowledge_qa
|
||||
name: 知识库问答助手
|
||||
description: 用于 SOP、制度和内部知识库问答
|
||||
applicable_questions:
|
||||
- SOP 问答
|
||||
- 制度问答
|
||||
|
||||
agent:
|
||||
role: 知识库问答专家
|
||||
goal: 基于知识库回答用户问题
|
||||
system_prompt: ""
|
||||
instructions:
|
||||
- 回答必须基于检索内容
|
||||
|
||||
rag:
|
||||
enabled: true
|
||||
collection: knowledge_qa
|
||||
top_k: 5
|
||||
|
||||
tools:
|
||||
- generate_action_items
|
||||
|
||||
output:
|
||||
type: general_answer
|
||||
|
||||
audit:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
`agent.system_prompt` 为可选字段。配置了非空值时,Agent Core 优先使用该字段作为系统提示词;为空或缺失时,由 `role`、`goal` 和 `instructions` 组合生成系统提示词。
|
||||
|
||||
`applicable_questions` 作为页面展示字段,若缺失可显示为空列表。
|
||||
|
||||
## 4. 场景加载流程
|
||||
|
||||
1. 读取 `settings.SCENARIO_CONFIG_DIR`。
|
||||
2. 遍历 `.yaml` 和 `.yml` 文件。
|
||||
3. 使用 YAML parser 转为 dict。
|
||||
4. 调用 `validate_scenario()`。
|
||||
5. 转换为 `ScenarioConfig` dataclass 或普通 dict。
|
||||
6. 按文件名或配置顺序返回。
|
||||
|
||||
为了便于复试修改,V1 不需要强缓存;若加缓存,应提供清理方式或在 DEBUG 下禁用缓存。
|
||||
|
||||
## 5. 场景校验规则
|
||||
|
||||
必填字段:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `description`
|
||||
- `agent.role`
|
||||
- `agent.goal`
|
||||
- `agent.instructions`
|
||||
- `rag.enabled`
|
||||
- `tools`
|
||||
- `output.type`
|
||||
- `audit.enabled`
|
||||
|
||||
校验失败时返回包含文件名、字段路径、错误原因的结果。列表页可以跳过非法场景并展示错误摘要。
|
||||
|
||||
## 6. 页面设计
|
||||
|
||||
首页路径:`/`
|
||||
|
||||
展示:
|
||||
|
||||
- 场景名称。
|
||||
- 场景描述。
|
||||
- 适用题型。
|
||||
- RAG 是否启用。
|
||||
- 工具数量。
|
||||
- 进入对话按钮。
|
||||
|
||||
可选详情页:`/scenarios/<scenario_id>/`。V1 可以把详情合并到 Chat 页面。
|
||||
|
||||
## 7. 服务函数设计
|
||||
|
||||
```python
|
||||
def list_scenarios() -> list[ScenarioConfig]:
|
||||
"""读取配置目录中的合法场景,非法场景以错误摘要返回给页面。"""
|
||||
|
||||
def get_scenario(scenario_id: str) -> ScenarioConfig:
|
||||
"""按场景 ID 返回完整配置,找不到时抛出 ScenarioNotFound。"""
|
||||
|
||||
def validate_scenario(config: dict) -> ValidationResult:
|
||||
"""校验必填字段、字段类型、工具名称和输出类型。"""
|
||||
```
|
||||
|
||||
`get_scenario()` 找不到时抛出业务异常,例如 `ScenarioNotFound`,由 View 转成中文错误提示。
|
||||
|
||||
## 8. 异常处理
|
||||
|
||||
| 异常 | 处理 |
|
||||
|---|---|
|
||||
| 配置目录不存在 | 返回空列表和错误提示 |
|
||||
| YAML 语法错误 | 标记该文件无效 |
|
||||
| ID 重复 | 保留第一个,报告重复错误 |
|
||||
| 必填字段缺失 | 标记该场景无效 |
|
||||
| 工具不存在 | 场景仍可展示,但 Chat 执行时记录工具错误 |
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- 首页至少展示 5 个场景。
|
||||
- 场景配置来自 `configs/` 文件。
|
||||
- 非法配置有明确错误,不导致首页 500。
|
||||
- Chat 可通过 `scenario_id` 获取完整配置。
|
||||
@@ -1,127 +0,0 @@
|
||||
# 文档模块详细设计
|
||||
|
||||
## 1. 模块目标
|
||||
|
||||
Documents 模块让用户把复试题材料快速变成 Agent 可检索的知识库。V1 必须支持 `.txt`、`.md`、`.pdf` 和 `.docx`,保证常见复试材料可以进入 RAG。
|
||||
|
||||
## 2. 职责边界
|
||||
|
||||
负责:
|
||||
|
||||
- 文件上传表单和页面。
|
||||
- 文件保存与元数据记录。
|
||||
- 读取文本内容。
|
||||
- 调用 Agent Core RAG 入库。
|
||||
- 更新入库状态。
|
||||
|
||||
不负责:
|
||||
|
||||
- 不实现向量检索算法。
|
||||
- 不生成模型回答。
|
||||
- 不直接写审计日志。
|
||||
|
||||
## 3. 数据模型设计
|
||||
|
||||
模型:`UploadedDocument`
|
||||
|
||||
字段见 `docs/设计文档/3.数据库设计.md`。
|
||||
|
||||
常量:
|
||||
|
||||
```python
|
||||
STATUS_UPLOADED = "uploaded"
|
||||
STATUS_INDEXED = "indexed"
|
||||
STATUS_FAILED = "failed"
|
||||
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"}
|
||||
```
|
||||
|
||||
文件保存路径建议:
|
||||
|
||||
```text
|
||||
uploads/<scenario_id>/<YYYYMMDD>/<uuid>_<original_name>
|
||||
```
|
||||
|
||||
## 4. 文件上传流程
|
||||
|
||||
1. GET `/documents/upload/` 渲染上传表单。
|
||||
2. POST 校验 `scenario_id` 和文件。
|
||||
3. 调用 Scenarios 服务确认场景存在。
|
||||
4. 校验扩展名和文件大小。
|
||||
5. 保存文件。
|
||||
6. 创建 `UploadedDocument(status="uploaded")`。
|
||||
7. 跳转文件列表页并展示成功提示。
|
||||
|
||||
## 5. 文本抽取流程
|
||||
|
||||
抽取函数:
|
||||
|
||||
```python
|
||||
def extract_text(document: UploadedDocument) -> str:
|
||||
"""按文件类型抽取可入库纯文本,失败时抛出可展示的业务异常。"""
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `.txt`:优先 UTF-8,失败时尝试系统默认编码。
|
||||
- `.md`:UTF-8 读取,保留标题、列表和正文。
|
||||
- `.pdf`:抽取纯文本,不要求 OCR、表格还原和复杂版式理解。
|
||||
- `.docx`:抽取段落、标题和普通表格文本,不要求完整保留 Word 样式。
|
||||
- 空文本视为失败。
|
||||
- 文件不存在视为失败。
|
||||
|
||||
XLSX 暂不作为 V1 必须项,可作为后续结构化业务数据导入能力。
|
||||
|
||||
## 6. RAG 入库触发流程
|
||||
|
||||
POST `/documents/<id>/index/`
|
||||
|
||||
1. 获取 `UploadedDocument`。
|
||||
2. 调用 `extract_text()`。
|
||||
3. 调用 `agent_core.rag.ingest.ingest_document()`,传入 `document_id`、`scenario_id`、文件名和抽取文本。
|
||||
4. 成功后更新 `status="indexed"`,清空 `error_message`。
|
||||
5. 失败后更新 `status="failed"`,写入 `error_message`。
|
||||
6. 重定向回文件列表页。
|
||||
|
||||
入库动作必须使用 POST,避免 GET 触发写操作。
|
||||
|
||||
已入库或失败文档允许重新入库。重新入库前需要按 `document_id` 清理或覆盖旧 chunk,避免重复检索。
|
||||
|
||||
## 7. 页面设计
|
||||
|
||||
文件列表页展示:
|
||||
|
||||
- 文件名。
|
||||
- 场景 ID。
|
||||
- 文件类型。
|
||||
- 文件大小。
|
||||
- 状态。
|
||||
- 上传时间。
|
||||
- 入库按钮。
|
||||
- 错误信息。
|
||||
|
||||
上传页展示:
|
||||
|
||||
- 场景下拉框。
|
||||
- 文件控件。
|
||||
- 支持类型提示。
|
||||
- 表单错误。
|
||||
|
||||
## 8. 异常处理
|
||||
|
||||
| 异常 | 处理 |
|
||||
|---|---|
|
||||
| 场景不存在 | 表单错误 |
|
||||
| 文件为空 | 表单错误 |
|
||||
| 扩展名不支持 | 表单错误 |
|
||||
| 文件保存失败 | 页面提示失败 |
|
||||
| 文本为空 | 状态 failed |
|
||||
| RAG 入库失败 | 状态 failed 并保存原因 |
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- 可以上传 `.txt`、`.md`、`.pdf` 和 `.docx`。
|
||||
- 文件列表可看到记录。
|
||||
- 文件可按场景关联。
|
||||
- 入库成功状态变为 `indexed`。
|
||||
- 入库失败状态变为 `failed` 且可查看原因。
|
||||
- 入库失败或已入库文档可重新入库。
|
||||
@@ -1,118 +0,0 @@
|
||||
# 对话模块详细设计
|
||||
|
||||
## 1. 模块目标
|
||||
|
||||
Chat 模块负责复试演示中的主交互:用户选择场景后提交问题,系统展示 Agent 输出、引用、工具调用和审计入口。
|
||||
|
||||
## 2. 职责边界
|
||||
|
||||
负责:
|
||||
|
||||
- 对话页 GET/POST。
|
||||
- 用户输入表单校验。
|
||||
- 获取场景配置。
|
||||
- 调用 Agent Core。
|
||||
- 调用 Audit 服务写日志。
|
||||
- 渲染 AgentResult。
|
||||
|
||||
不负责:
|
||||
|
||||
- 不直接读取 YAML。
|
||||
- 不直接调用 LLM。
|
||||
- 不直接执行 RAG 和工具。
|
||||
- 不实现复杂多轮会话状态。
|
||||
|
||||
## 3. 页面设计
|
||||
|
||||
路径:`/chat/<scenario_id>/`
|
||||
|
||||
GET:
|
||||
|
||||
- 加载场景配置。
|
||||
- 展示场景摘要。
|
||||
- 加载当前场景下状态为 `indexed` 的文档列表。
|
||||
- 展示空表单。
|
||||
|
||||
POST:
|
||||
|
||||
- 校验输入。
|
||||
- 执行 Agent。
|
||||
- 写审计。
|
||||
- 展示结果和审计链接。
|
||||
|
||||
## 4. 表单设计
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 类型 | 规则 |
|
||||
|---|---|---|
|
||||
| `message` | textarea | 必填,最大 4000 字 |
|
||||
| `document_ids` | 多选 | 可选,只能选择当前场景下已入库文档 |
|
||||
|
||||
错误提示:
|
||||
|
||||
- 空输入:`请输入要咨询的问题。`
|
||||
- 超长输入:`问题过长,请控制在 4000 字以内。`
|
||||
- 文档不属于当前场景或未入库:`请选择当前场景下已入库的文档。`
|
||||
|
||||
## 5. Agent Core 调用流程
|
||||
|
||||
```python
|
||||
scenario = get_scenario(scenario_id)
|
||||
result = run_agent(
|
||||
scenario_config=scenario,
|
||||
user_input=form.cleaned_data["message"],
|
||||
options={"document_ids": form.cleaned_data.get("document_ids", [])}
|
||||
)
|
||||
```
|
||||
|
||||
Chat 只依赖 Agent Core 的统一返回对象,不关心内部是否使用 RAG、工具或真实模型。
|
||||
|
||||
未选择文档时,`document_ids` 传空列表或不传,由 Agent Core 默认使用当前场景全部已入库文档。
|
||||
|
||||
## 6. 结果展示设计
|
||||
|
||||
优先级:
|
||||
|
||||
1. 如果 `structured_output` 不为空,展示结构化 JSON 或字段化结果。
|
||||
2. 展示 `answer`。
|
||||
3. 展示 `references`。
|
||||
4. 展示 `tool_calls`。
|
||||
5. 展示 `latency_ms`、`model_name`、`status`。
|
||||
6. 如果有 `error`,展示中文错误提示。
|
||||
|
||||
结构化解析失败时,页面仍展示 `raw_output` 或 `answer`。
|
||||
|
||||
## 7. 审计日志写入流程
|
||||
|
||||
Agent Core 返回后调用:
|
||||
|
||||
```python
|
||||
audit_log = create_audit_log(
|
||||
scenario_id=scenario.id,
|
||||
scenario_name=scenario.name,
|
||||
user_input=message,
|
||||
agent_result=result,
|
||||
)
|
||||
```
|
||||
|
||||
如果 Agent Core 抛异常,Chat 应构造失败结果并继续写失败审计。
|
||||
|
||||
## 8. 异常处理
|
||||
|
||||
| 异常 | 处理 |
|
||||
|---|---|
|
||||
| 场景不存在 | 显示错误并返回首页入口 |
|
||||
| 表单无效 | 留在页面并显示表单错误 |
|
||||
| Agent Core 抛异常 | 构造 failed AgentResult,写审计 |
|
||||
| 审计写入失败 | 页面提示审计失败,但展示 Agent 输出 |
|
||||
| LLM 配置缺失 | 展示模型配置缺失 |
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- 从首页可进入对话页。
|
||||
- 可提交问题并渲染 AgentResult。
|
||||
- 可选择本次对话使用的文档范围;未选择时默认使用当前场景全部已入库文档。
|
||||
- 失败时有中文提示。
|
||||
- 成功和失败都尽量写入审计。
|
||||
- View 中没有 RAG、工具、LLM 的细节实现。
|
||||
@@ -1,121 +0,0 @@
|
||||
# 审计模块详细设计
|
||||
|
||||
## 1. 模块目标
|
||||
|
||||
Audit 模块记录 Agent 执行过程,使演示者能够解释一次输出的来源、工具调用和模型结果。它是系统从“普通问答页面”变成“可追踪业务 Agent”的关键。
|
||||
|
||||
## 2. 职责边界
|
||||
|
||||
负责:
|
||||
|
||||
- `AgentAuditLog` 模型。
|
||||
- 审计写入服务。
|
||||
- 审计列表页。
|
||||
- 审计详情页。
|
||||
- 敏感信息过滤。
|
||||
|
||||
不负责:
|
||||
|
||||
- 不执行 Agent。
|
||||
- 不执行 RAG。
|
||||
- 不执行工具。
|
||||
- 不调用模型。
|
||||
|
||||
## 3. 数据模型设计
|
||||
|
||||
模型:`AgentAuditLog`
|
||||
|
||||
字段见 `docs/设计文档/3.数据库设计.md`。
|
||||
|
||||
JSON 字段默认值必须使用函数,例如 `default=list`、`default=dict`,避免多实例共享同一对象。
|
||||
|
||||
## 4. 日志写入流程
|
||||
|
||||
服务函数:
|
||||
|
||||
```python
|
||||
def create_audit_log(
|
||||
scenario_id: str,
|
||||
scenario_name: str,
|
||||
user_input: str,
|
||||
agent_result: AgentResult,
|
||||
) -> AgentAuditLog:
|
||||
"""将 AgentResult 映射为 AgentAuditLog,并在保存前做敏感信息脱敏。"""
|
||||
```
|
||||
|
||||
写入映射:
|
||||
|
||||
- `agent_result.references` -> `retrieved_chunks`
|
||||
- `agent_result.tool_calls` -> `tool_calls`
|
||||
- `agent_result.structured_output` -> `structured_output`
|
||||
- `agent_result.answer` -> `final_answer`
|
||||
- `agent_result.raw_output` -> `raw_output`
|
||||
- `agent_result.model_name` -> `model_name`
|
||||
- `agent_result.latency_ms` -> `latency_ms`
|
||||
- `agent_result.status` -> `status`
|
||||
- `agent_result.error` -> `error_message`
|
||||
|
||||
## 5. 日志列表页设计
|
||||
|
||||
路径:`/audit/`
|
||||
|
||||
查询:
|
||||
|
||||
- 默认按创建时间倒序。
|
||||
- V1 可不做分页,若日志较多再加 Django Paginator。
|
||||
|
||||
展示:
|
||||
|
||||
- ID。
|
||||
- 场景名称。
|
||||
- 用户输入前 80 字。
|
||||
- 状态。
|
||||
- 模型名。
|
||||
- 耗时。
|
||||
- 创建时间。
|
||||
- 详情链接。
|
||||
|
||||
## 6. 日志详情页设计
|
||||
|
||||
路径:`/audit/<log_id>/`
|
||||
|
||||
展示:
|
||||
|
||||
- 基础信息。
|
||||
- 用户输入。
|
||||
- 最终回答。
|
||||
- 结构化输出。
|
||||
- RAG 检索片段。
|
||||
- 工具调用。
|
||||
- 原始输出。
|
||||
- 错误信息。
|
||||
|
||||
JSON 可用格式化后的 `<pre>` 展示。
|
||||
|
||||
## 7. 敏感信息处理
|
||||
|
||||
不得保存:
|
||||
|
||||
- `LLM_API_KEY`
|
||||
- 完整环境变量 dump
|
||||
- 用户机器上的敏感绝对路径
|
||||
- Docker secret 或 token
|
||||
|
||||
如错误信息来自异常对象,应在保存前做简单脱敏,至少替换 API Key 值。
|
||||
|
||||
## 8. 异常处理
|
||||
|
||||
| 异常 | 处理 |
|
||||
|---|---|
|
||||
| AgentResult 字段缺失 | 使用默认空值 |
|
||||
| JSON 不可序列化 | 转为字符串或空对象 |
|
||||
| 日志不存在 | 返回 404 |
|
||||
| 写入失败 | 抛给 Chat,由 Chat 展示审计失败提示 |
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- 每次对话成功后有审计日志。
|
||||
- Agent 失败也有失败日志。
|
||||
- 列表页可查看日志摘要。
|
||||
- 详情页可查看输入、输出、引用和工具调用。
|
||||
- 日志不包含 API Key。
|
||||
@@ -1,259 +0,0 @@
|
||||
# 智能核心模块详细设计
|
||||
|
||||
## 1. 模块目标
|
||||
|
||||
Agent Core 提供独立于 Django View 的智能编排能力。它消费场景配置,执行 RAG、工具、模型调用和结构化解析,最终返回统一 AgentResult。
|
||||
|
||||
## 2. 职责边界
|
||||
|
||||
负责:
|
||||
|
||||
- Agent 编排。
|
||||
- 场景配置对象消费。
|
||||
- RAG 入库和检索。
|
||||
- 工具注册与执行。
|
||||
- LLM Provider 与 Embedding Provider。
|
||||
- 结构化输出解析。
|
||||
- AgentResult 定义。
|
||||
|
||||
不负责:
|
||||
|
||||
- 不渲染页面。
|
||||
- 不处理 Django 表单。
|
||||
- 不保存 Django Model。
|
||||
- 不管理登录权限。
|
||||
|
||||
## 3. 子模块划分
|
||||
|
||||
```text
|
||||
agent_core/
|
||||
orchestrator.py
|
||||
scenario_loader.py
|
||||
llm_provider.py
|
||||
tool_registry.py
|
||||
structured_output.py
|
||||
rag/
|
||||
ingest.py
|
||||
retriever.py
|
||||
tools/
|
||||
builtin_tools.py
|
||||
schemas/
|
||||
outputs.py
|
||||
```
|
||||
|
||||
`scenario_loader.py` 可作为非 Django 环境下加载配置的工具;Django 场景展示仍由 `apps.scenarios` 负责。
|
||||
|
||||
## 4. Orchestrator 设计
|
||||
|
||||
入口:
|
||||
|
||||
```python
|
||||
def run_agent(scenario_config, user_input: str, options: dict | None = None) -> AgentResult:
|
||||
"""执行一次 Agent 编排,options 可包含 document_ids 等运行期约束。"""
|
||||
```
|
||||
|
||||
流程:
|
||||
|
||||
1. 记录开始时间。
|
||||
2. 根据 `rag.enabled`、`scenario_id` 和可选 `document_ids` 检索引用。
|
||||
3. 根据 `tools` 执行或准备工具结果。
|
||||
4. 构造 messages。
|
||||
5. 调用 LLM Provider。
|
||||
6. 解析结构化输出。
|
||||
7. 计算耗时。
|
||||
8. 返回 `AgentResult(status="success")`。
|
||||
9. 捕获可恢复异常并返回 `status="failed"`。
|
||||
|
||||
V1 在缺少 LLM 或 Embedding 配置时必须返回清晰失败结果。测试代码可以使用 mock provider,但 V1 验收链路必须通过真实 OpenAI 兼容 LLM、Embedding 和 Chroma。
|
||||
|
||||
## 5. Scenario Loader 设计
|
||||
|
||||
Agent Core 的 Scenario Loader 用于脚本、测试或后续独立服务场景。它不依赖 Django View,可以复用 Scenarios 模块的字段规范。
|
||||
|
||||
接口:
|
||||
|
||||
```python
|
||||
load_scenario(path: str) -> dict
|
||||
load_scenarios(directory: str) -> list[dict]
|
||||
```
|
||||
|
||||
## 6. RAG 设计
|
||||
|
||||
入库接口:
|
||||
|
||||
```python
|
||||
def ingest_document(
|
||||
document_id: int,
|
||||
scenario_id: str,
|
||||
source_file: str,
|
||||
text: str,
|
||||
collection: str,
|
||||
) -> IngestResult:
|
||||
"""切分文档、生成 embedding,并写入 Chroma。重新入库时覆盖同一 document_id 的旧 chunk。"""
|
||||
```
|
||||
|
||||
检索接口:
|
||||
|
||||
```python
|
||||
def retrieve(
|
||||
scenario_id: str,
|
||||
query: str,
|
||||
collection: str,
|
||||
top_k: int = 5,
|
||||
document_ids: list[int] | None = None,
|
||||
) -> list[ReferenceChunk]:
|
||||
"""按场景和可选文档范围执行向量检索,返回可审计引用片段。"""
|
||||
```
|
||||
|
||||
切分策略:
|
||||
|
||||
- 默认 chunk size 800 到 1000 字。
|
||||
- overlap 100 到 150 字。
|
||||
- metadata 包含 `scenario_id`、`document_id`、`source_file`、`chunk_id`。
|
||||
|
||||
RAG 入库和检索必须使用 Embedding Provider 与 Chroma。单元测试桩或开发阶段临时验证方案不属于 V1 验收设计。
|
||||
|
||||
## 7. Tool Registry 设计
|
||||
|
||||
工具注册:
|
||||
|
||||
```python
|
||||
registry.register("calculate_rate", calculate_rate)
|
||||
registry.get("calculate_rate")
|
||||
registry.run("calculate_rate", **kwargs)
|
||||
```
|
||||
|
||||
工具结果统一:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "calculate_rate",
|
||||
"success": true,
|
||||
"arguments": {},
|
||||
"result": {},
|
||||
"error": ""
|
||||
}
|
||||
```
|
||||
|
||||
内置工具:
|
||||
|
||||
- `calculate_rate`
|
||||
- `query_demo_records`
|
||||
- `check_required_fields`
|
||||
- `generate_action_items`
|
||||
|
||||
工具函数不得直接读取 API Key 或执行无审计的外部副作用。
|
||||
|
||||
## 8. LLM Provider 设计
|
||||
|
||||
接口:
|
||||
|
||||
```python
|
||||
class LLMProvider:
|
||||
def generate(self, messages: list[dict], response_format: dict | None = None) -> LLMResponse:
|
||||
"""调用 OpenAI 兼容 Chat Completions 接口并返回统一响应对象。"""
|
||||
```
|
||||
|
||||
配置来源:
|
||||
|
||||
- `LLM_API_KEY`
|
||||
- `LLM_BASE_URL`
|
||||
- `LLM_MODEL`
|
||||
|
||||
Provider 对外隐藏供应商差异,Orchestrator 只处理 `LLMResponse.content`、`LLMResponse.model_name` 和错误信息。供应商可自主选择 OpenAI、硅基流动等 OpenAI 兼容服务。
|
||||
|
||||
Embedding Provider 接口:
|
||||
|
||||
```python
|
||||
class EmbeddingProvider:
|
||||
def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
||||
"""调用 OpenAI 兼容 Embeddings 接口,返回与输入文本一一对应的向量。"""
|
||||
```
|
||||
|
||||
配置来源:
|
||||
|
||||
- `EMBEDDING_API_KEY`
|
||||
- `EMBEDDING_BASE_URL`
|
||||
- `EMBEDDING_MODEL`
|
||||
|
||||
当 `EMBEDDING_API_KEY` 或 `EMBEDDING_BASE_URL` 为空时,可以复用 `LLM_API_KEY` 和 `LLM_BASE_URL`。
|
||||
|
||||
## 9. Structured Output 设计
|
||||
|
||||
接口:
|
||||
|
||||
```python
|
||||
def parse_structured_output(raw_output: str, output_type: str) -> ParseResult:
|
||||
"""优先解析 JSON,并根据输出类型返回结构化结果或解析错误。"""
|
||||
```
|
||||
|
||||
策略:
|
||||
|
||||
- 优先解析 JSON。
|
||||
- 根据 `output_type` 做字段补齐或轻校验。
|
||||
- 失败时返回 `success=False`,保留 `raw_output`。
|
||||
- 不因结构化解析失败导致整个 Agent 流程崩溃。
|
||||
|
||||
## 10. AgentResult 设计
|
||||
|
||||
建议 dataclass:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class AgentResult:
|
||||
answer: str
|
||||
structured_output: dict
|
||||
references: list
|
||||
tool_calls: list
|
||||
raw_output: str
|
||||
model_name: str
|
||||
latency_ms: int
|
||||
status: str
|
||||
error: str = ""
|
||||
```
|
||||
|
||||
所有字段必须有默认值或构造时明确传入,保证 Audit 模块写入稳定。
|
||||
|
||||
## 11. Adapter 扩展设计
|
||||
|
||||
统一接口:
|
||||
|
||||
```python
|
||||
class AgentEngine:
|
||||
def run_agent(self, scenario_config, user_input: str, options: dict | None = None) -> AgentResult:
|
||||
"""保持与顶层 run_agent 函数一致的输入输出合约。"""
|
||||
```
|
||||
|
||||
V1 实现:
|
||||
|
||||
- `LightweightOrchestrator`
|
||||
|
||||
后续扩展:
|
||||
|
||||
- `DifyAdapter`
|
||||
- `OpenAIAgentsAdapter`
|
||||
- `LangGraphAdapter`
|
||||
|
||||
Adapter 只能替换编排实现,不能改变 Django 层依赖的 AgentResult 合约。
|
||||
|
||||
## 12. 异常处理
|
||||
|
||||
| 异常 | 处理 |
|
||||
|---|---|
|
||||
| RAG 检索失败 | 记录错误,允许继续或返回 failed |
|
||||
| 工具不存在 | 记录失败工具调用 |
|
||||
| 工具执行异常 | 捕获并返回失败工具结果 |
|
||||
| LLM 配置缺失 | 返回 failed AgentResult |
|
||||
| LLM 调用失败 | 返回 failed AgentResult |
|
||||
| JSON 解析失败 | 返回 success 但带解析错误,展示 raw output |
|
||||
|
||||
## 13. 验收标准
|
||||
|
||||
- Chat 可以调用 `run_agent()`。
|
||||
- 返回对象字段稳定完整。
|
||||
- RAG 按 `scenario_id` 隔离。
|
||||
- RAG 支持按 `document_ids` 限定本次对话的文档范围。
|
||||
- 工具调用结果格式统一。
|
||||
- LLM 与 Embedding 配置从环境变量读取。
|
||||
- 结构化解析失败不导致页面崩溃。
|
||||
- Agent Core 不依赖 Django View。
|
||||
@@ -1,583 +0,0 @@
|
||||
# Universal Agent Demo Framework V1 需求文档
|
||||
|
||||
## 1. 项目背景
|
||||
|
||||
本项目用于复试展示。复试题目暂时未知,但大概率围绕企业生产、质量、客服、财务、SOP、文档审核、工单处理等场景。
|
||||
|
||||
项目目标不是提前猜中某一个具体业务题,而是先搭建一个通用 AI Agent Demo 底座。拿到复试题目后,可以通过修改场景配置、上传知识库、补充少量业务工具,快速生成一个可演示的业务 Agent 系统。
|
||||
|
||||
核心理念:
|
||||
|
||||
```text
|
||||
业务 Agent = 场景配置 + 知识库 + 工具集 + 输出模板 + 审计日志 + 模型适配器
|
||||
```
|
||||
|
||||
## 2. 项目目标
|
||||
|
||||
V1 版本目标是实现一个可运行、可演示、可快速改题的基础平台。
|
||||
|
||||
系统需要支持:
|
||||
|
||||
- 通过配置快速创建不同业务 Agent。
|
||||
- 支持上传文档并构建 RAG 知识库。
|
||||
- 支持根据场景调用内置业务工具。
|
||||
- 支持结构化输出,方便展示报告、风险点、建议动作等结果。
|
||||
- 支持审计日志,记录用户输入、检索内容、工具调用和模型输出。
|
||||
- 支持 Docker 一键启动,降低复试现场环境风险。
|
||||
- 支持快速替换大模型 API。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
V1 不追求完整企业级平台能力,以下内容暂不作为第一版重点:
|
||||
|
||||
- 复杂权限系统。
|
||||
- 多租户管理。
|
||||
- 完整工作流引擎。
|
||||
- 复杂多 Agent 协作。
|
||||
- 前后端分离架构。
|
||||
- 深度集成 Dify。
|
||||
- 生产级高并发优化。
|
||||
- 完整在线文档协同编辑。
|
||||
|
||||
## 4. 技术方案
|
||||
|
||||
### 4.1 总体架构
|
||||
|
||||
V1 使用 Django 单体应用承载企业系统外壳,Agent Core 作为独立 Python 模块承载智能编排能力。
|
||||
|
||||
```text
|
||||
Django Monolith
|
||||
|
|
||||
|-- Web UI
|
||||
| |-- 场景选择
|
||||
| |-- Agent 对话
|
||||
| |-- 文件上传
|
||||
| |-- 结构化结果展示
|
||||
| |-- 审计日志查看
|
||||
|
|
||||
|-- Django Admin
|
||||
| |-- 上传文件管理
|
||||
| |-- 审计日志管理
|
||||
| |-- 示例业务数据管理
|
||||
|
|
||||
|-- Agent Core
|
||||
| |-- 场景配置加载
|
||||
| |-- RAG 检索
|
||||
| |-- 工具注册与调用
|
||||
| |-- 大模型适配
|
||||
| |-- 结构化输出解析
|
||||
|
|
||||
|-- Storage
|
||||
|-- SQLite
|
||||
|-- Chroma
|
||||
|-- Uploaded Files
|
||||
```
|
||||
|
||||
### 4.2 技术栈
|
||||
|
||||
| 模块 | 技术 | 说明 |
|
||||
|---|---|---|
|
||||
| Web 框架 | Django | 负责页面、模型、后台、文件上传和业务管理 |
|
||||
| 页面渲染 | Django Templates + Bootstrap | 降低前端复杂度,快速完成 Demo |
|
||||
| 数据库 | SQLite | V1 默认数据库,适合本地演示 |
|
||||
| 向量库 | Chroma | 本地 RAG 知识库 |
|
||||
| Agent Core | 自研轻量 Orchestrator | 保证可控、易讲解、易改题 |
|
||||
| LLM 接入 | OpenAI API 兼容接口 | 方便切换 OpenAI、硅基流动等兼容服务、国产模型或本地代理 |
|
||||
| Embedding 接入 | OpenAI API 兼容接口 | 用于文档向量化,供应商可自主选择 |
|
||||
| 部署 | Docker + Docker Compose | 支持一键启动 |
|
||||
|
||||
## 5. 用户角色
|
||||
|
||||
V1 只设计一个主要用户角色:
|
||||
|
||||
### Demo 操作者
|
||||
|
||||
通常是复试时的展示者,负责选择场景、上传材料、输入问题、查看 Agent 输出和审计记录。
|
||||
|
||||
暂不区分管理员、业务人员、审核人员等复杂角色。
|
||||
|
||||
## 6. 核心使用流程
|
||||
|
||||
### 6.1 复试前准备流程
|
||||
|
||||
1. 启动系统。
|
||||
2. 选择或复制一个已有场景模板。
|
||||
3. 根据题目修改场景配置。
|
||||
4. 上传题目相关文档。
|
||||
5. 如有必要,补充一个业务工具函数。
|
||||
6. 运行一次测试对话。
|
||||
7. 使用审计日志确认 RAG、工具调用和输出链路正常。
|
||||
|
||||
### 6.2 复试演示流程
|
||||
|
||||
1. 打开系统首页。
|
||||
2. 展示系统支持多个业务场景。
|
||||
3. 选择当前题目对应的 Agent。
|
||||
4. 上传或选择知识库文档。
|
||||
5. 输入业务问题。
|
||||
6. 展示 Agent 的结构化输出。
|
||||
7. 展示引用来源、工具调用和审计日志。
|
||||
8. 说明同一平台可通过配置切换到其他业务场景。
|
||||
|
||||
## 7. 场景模板
|
||||
|
||||
V1 预置 5 类通用场景模板,用于覆盖大多数复试题型。
|
||||
|
||||
| 模板 ID | 模板名称 | 适用题型 |
|
||||
|---|---|---|
|
||||
| knowledge_qa | 知识库问答助手 | SOP、制度、客服知识库、内部文档问答 |
|
||||
| document_review | 文档审核助手 | 合同审核、制度审核、SOP 审核、材料合规检查 |
|
||||
| ticket_assistant | 工单处理助手 | 客服工单、售后工单、运维工单 |
|
||||
| quality_analysis | 质量异常分析助手 | 生产质量、缺陷分析、原因定位 |
|
||||
| risk_audit | 风险审核助手 | 财务审核、采购审核、报销审核、合同风险 |
|
||||
|
||||
## 8. 场景配置需求
|
||||
|
||||
场景应通过 YAML 或 JSON 文件定义,避免把业务逻辑写死在代码中。
|
||||
|
||||
配置内容包括:
|
||||
|
||||
- 场景 ID。
|
||||
- 场景名称。
|
||||
- 场景描述。
|
||||
- Agent 角色。
|
||||
- Agent 任务目标。
|
||||
- 系统提示词 可选。
|
||||
- 是否启用 RAG。
|
||||
- RAG 检索参数。
|
||||
- 可用工具列表。
|
||||
- 输出模板类型。
|
||||
- 审计策略。
|
||||
|
||||
示例:
|
||||
|
||||
```yaml
|
||||
id: quality_analysis
|
||||
name: 质量异常分析助手
|
||||
description: 用于分析生产质量异常、检索 SOP、生成处理建议
|
||||
|
||||
agent:
|
||||
role: 质量管理专家
|
||||
goal: 根据用户问题、知识库和工具结果,输出可执行的质量分析报告
|
||||
system_prompt: 你是质量管理专家,需要基于知识库和工具结果输出结构化质量分析报告
|
||||
instructions:
|
||||
- 回答必须基于知识库或工具结果
|
||||
- 不确定时必须说明缺失信息
|
||||
- 涉及质量风险时给出风险等级
|
||||
|
||||
rag:
|
||||
enabled: true
|
||||
collection: quality_docs
|
||||
top_k: 5
|
||||
|
||||
tools:
|
||||
- query_demo_records
|
||||
- calculate_rate
|
||||
|
||||
output:
|
||||
type: quality_report
|
||||
|
||||
audit:
|
||||
enabled: true
|
||||
log_retrieval: true
|
||||
log_tool_calls: true
|
||||
```
|
||||
|
||||
## 9. 功能需求
|
||||
|
||||
### 9.1 首页
|
||||
|
||||
首页需要展示系统定位和可用场景列表。
|
||||
|
||||
页面能力:
|
||||
|
||||
- 查看所有 Agent 场景。
|
||||
- 进入某个场景的对话页。
|
||||
- 查看最近审计日志入口。
|
||||
- 查看文件上传入口。
|
||||
|
||||
### 9.2 场景选择
|
||||
|
||||
系统需要支持从预置模板中选择业务场景。
|
||||
|
||||
V1 从 YAML 配置文件读取场景。后台管理只负责上传文件、审计日志和示例业务数据管理,不作为场景配置入口。
|
||||
|
||||
最低要求:
|
||||
|
||||
- 展示场景名称。
|
||||
- 展示场景描述。
|
||||
- 展示场景适用题型。
|
||||
- 点击后进入对应 Agent 对话页。
|
||||
|
||||
### 9.3 Agent 对话
|
||||
|
||||
Agent 对话页是核心演示页面。
|
||||
|
||||
页面需要包含:
|
||||
|
||||
- 当前场景名称。
|
||||
- 用户输入框。
|
||||
- 文件上下文选择,可多选当前场景已入库文档;不选时默认使用当前场景全部已入库文档。
|
||||
- Agent 输出区域。
|
||||
- 结构化结果展示区域。
|
||||
- 引用片段展示区域。
|
||||
- 工具调用展示区域。
|
||||
|
||||
Agent 执行流程:
|
||||
|
||||
1. 接收用户问题。
|
||||
2. 加载当前场景配置。
|
||||
3. 如果启用 RAG,则检索相关知识片段。
|
||||
4. 根据场景判断是否调用工具。
|
||||
5. 调用大模型生成结果。
|
||||
6. 解析为结构化输出。
|
||||
7. 写入审计日志。
|
||||
8. 返回页面展示。
|
||||
|
||||
### 9.4 文件上传
|
||||
|
||||
系统需要支持上传题目材料和知识库文档。
|
||||
|
||||
V1 支持的文件类型:
|
||||
|
||||
- TXT
|
||||
- Markdown
|
||||
- PDF
|
||||
- DOCX
|
||||
- XLSX 可作为后续增强
|
||||
|
||||
文件上传后需要保存:
|
||||
|
||||
- 原始文件名。
|
||||
- 文件路径。
|
||||
- 文件类型。
|
||||
- 上传时间。
|
||||
- 关联场景。
|
||||
- 是否已入库。
|
||||
|
||||
### 9.5 RAG 知识库
|
||||
|
||||
系统需要支持将上传文档写入向量库,并在 Agent 对话时检索。
|
||||
|
||||
V1 RAG 流程:
|
||||
|
||||
1. 读取上传文件文本。
|
||||
2. 按固定长度切分文本。
|
||||
3. 生成 embedding。
|
||||
4. 写入 Chroma collection。
|
||||
5. 对话时根据用户问题检索 top_k 片段。
|
||||
6. 将片段作为上下文传给 Agent。
|
||||
7. 在结果中展示引用来源。
|
||||
|
||||
### 9.6 工具调用
|
||||
|
||||
系统需要提供一个工具注册机制。
|
||||
|
||||
V1 内置工具建议包括:
|
||||
|
||||
| 工具名 | 用途 |
|
||||
|---|---|
|
||||
| calculate_rate | 计算比例、缺陷率、通过率等指标 |
|
||||
| query_demo_records | 查询模拟业务数据 |
|
||||
| check_required_fields | 检查文档或表单必填项 |
|
||||
| generate_action_items | 根据问题生成行动项 |
|
||||
|
||||
工具调用需要记录到审计日志中。
|
||||
|
||||
### 9.7 结构化输出
|
||||
|
||||
不同场景需要不同输出模板。
|
||||
|
||||
V1 至少支持以下输出类型:
|
||||
|
||||
#### 通用问答输出
|
||||
|
||||
- answer
|
||||
- references
|
||||
- confidence
|
||||
|
||||
#### 文档审核输出
|
||||
|
||||
- summary
|
||||
- issues
|
||||
- risk_level
|
||||
- suggestions
|
||||
- missing_items
|
||||
- references
|
||||
|
||||
#### 工单处理输出
|
||||
|
||||
- reply
|
||||
- category
|
||||
- priority
|
||||
- suggested_action
|
||||
- need_human_review
|
||||
|
||||
#### 质量分析输出
|
||||
|
||||
- summary
|
||||
- possible_causes
|
||||
- evidence
|
||||
- risk_level
|
||||
- suggested_actions
|
||||
- references
|
||||
|
||||
### 9.8 审计日志
|
||||
|
||||
系统需要记录每次 Agent 执行过程。
|
||||
|
||||
审计字段:
|
||||
|
||||
- 日志 ID。
|
||||
- 场景 ID。
|
||||
- 用户输入。
|
||||
- 检索片段。
|
||||
- 工具调用记录。
|
||||
- 模型名称。
|
||||
- 结构化输出。
|
||||
- 原始输出。
|
||||
- 执行耗时。
|
||||
- 创建时间。
|
||||
|
||||
审计日志页面需要支持:
|
||||
|
||||
- 查看日志列表。
|
||||
- 查看单条日志详情。
|
||||
- 展示检索内容。
|
||||
- 展示工具调用。
|
||||
- 展示最终输出。
|
||||
|
||||
### 9.9 模型适配
|
||||
|
||||
系统需要通过统一接口调用大模型,避免模型 API 写死。
|
||||
|
||||
V1 模型适配器需要支持:
|
||||
|
||||
- 从环境变量读取 API Key。
|
||||
- 从环境变量读取 Base URL。
|
||||
- 从环境变量读取 Model Name。
|
||||
- 支持 OpenAI API 兼容格式,可接入 OpenAI、硅基流动等兼容供应商。
|
||||
- 支持独立配置 Embedding 模型,用于 RAG 入库和检索。
|
||||
|
||||
环境变量示例:
|
||||
|
||||
```env
|
||||
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
|
||||
DJANGO_DEBUG=true
|
||||
DJANGO_ALLOWED_HOSTS=*
|
||||
|
||||
LLM_API_KEY=your_api_key
|
||||
LLM_BASE_URL=https://api.openai.com/v1
|
||||
LLM_MODEL=gpt-4.1-mini
|
||||
EMBEDDING_API_KEY=
|
||||
EMBEDDING_BASE_URL=
|
||||
EMBEDDING_MODEL=text-embedding-3-small
|
||||
SCENARIO_CONFIG_DIR=configs
|
||||
UPLOAD_ROOT=data/uploads
|
||||
CHROMA_PATH=data/chroma
|
||||
```
|
||||
|
||||
补充说明:
|
||||
|
||||
- `EMBEDDING_API_KEY` 为空时可复用 `LLM_API_KEY`。
|
||||
- `EMBEDDING_BASE_URL` 为空时可复用 `LLM_BASE_URL`。
|
||||
- `.env.example` 仅作为模板,不允许放真实密钥。
|
||||
- 当前 V1 代码会在 settings 初始化时自动读取根目录 `.env`,本地运行与 `pytest` 可复用同一套配置;当前 Docker Compose 配置也通过 `env_file` 读取 `.env`。
|
||||
|
||||
## 10. Dify 集成策略
|
||||
|
||||
V1 不把 Dify 作为核心依赖。
|
||||
|
||||
原因:
|
||||
|
||||
- 复试现场需要最大程度保证可控。
|
||||
- 自研 Agent Core 更方便解释架构设计。
|
||||
- 题目未知时,直接依赖外部平台会增加部署和调试风险。
|
||||
- Django + Agent Core 已能覆盖第一版演示需求。
|
||||
|
||||
系统预留 Agent Engine Adapter 概念,后续可接入 Dify、OpenAI Agents SDK 或其他企业 AI 平台。
|
||||
|
||||
V1 默认引擎:
|
||||
|
||||
```text
|
||||
Lightweight Orchestrator
|
||||
```
|
||||
|
||||
后续可扩展:
|
||||
|
||||
```text
|
||||
Dify API Adapter
|
||||
OpenAI Agents SDK Adapter
|
||||
LangGraph Adapter
|
||||
```
|
||||
|
||||
## 11. Docker 部署需求
|
||||
|
||||
系统需要支持 Docker Compose 一键启动。
|
||||
|
||||
基础命令:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
V1 容器内容:
|
||||
|
||||
- Django Web 服务。
|
||||
- SQLite 数据文件挂载。
|
||||
- Chroma 数据目录挂载。
|
||||
- 上传文件目录挂载。
|
||||
|
||||
V1 暂不强制引入 PostgreSQL。如果后续需要更正式的部署效果,可以在 Docker Compose 中增加 PostgreSQL 服务。
|
||||
|
||||
## 12. 推荐项目结构
|
||||
|
||||
```text
|
||||
universal-agent-demo/
|
||||
manage.py
|
||||
requirements.txt
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.env.example
|
||||
README.md
|
||||
|
||||
config/
|
||||
settings.py
|
||||
urls.py
|
||||
wsgi.py
|
||||
asgi.py
|
||||
|
||||
apps/
|
||||
scenarios/
|
||||
models.py
|
||||
admin.py
|
||||
services.py
|
||||
|
||||
chat/
|
||||
views.py
|
||||
urls.py
|
||||
forms.py
|
||||
|
||||
documents/
|
||||
models.py
|
||||
views.py
|
||||
services.py
|
||||
|
||||
audit/
|
||||
models.py
|
||||
admin.py
|
||||
services.py
|
||||
|
||||
agent_core/
|
||||
orchestrator.py
|
||||
scenario_loader.py
|
||||
llm_provider.py
|
||||
tool_registry.py
|
||||
structured_output.py
|
||||
|
||||
rag/
|
||||
ingest.py
|
||||
retriever.py
|
||||
|
||||
tools/
|
||||
builtin_tools.py
|
||||
|
||||
schemas/
|
||||
outputs.py
|
||||
|
||||
configs/
|
||||
knowledge_qa.yaml
|
||||
document_review.yaml
|
||||
ticket_assistant.yaml
|
||||
quality_analysis.yaml
|
||||
risk_audit.yaml
|
||||
|
||||
data/
|
||||
uploads/
|
||||
chroma/
|
||||
db.sqlite3
|
||||
|
||||
templates/
|
||||
base.html
|
||||
home.html
|
||||
chat/index.html
|
||||
documents/upload.html
|
||||
audit/logs.html
|
||||
|
||||
static/
|
||||
css/
|
||||
js/
|
||||
```
|
||||
|
||||
## 13. 模块需求文档
|
||||
|
||||
V1 按 6 个核心模块拆分,具体模块需求见:
|
||||
|
||||
| 模块 | 文档 |
|
||||
|---|---|
|
||||
| 配置 | `docs/需求分析/3.配置模块需求.md` |
|
||||
| 场景 | `docs/需求分析/4.场景模块需求.md` |
|
||||
| 文档 | `docs/需求分析/5.文档模块需求.md` |
|
||||
| 对话 | `docs/需求分析/6.对话模块需求.md` |
|
||||
| 审计 | `docs/需求分析/7.审计模块需求.md` |
|
||||
| 智能核心 | `docs/需求分析/8.智能核心模块需求.md` |
|
||||
|
||||
模块总览见:
|
||||
|
||||
```text
|
||||
docs/需求分析/2.模块需求索引.md
|
||||
```
|
||||
|
||||
## 14. V1 验收标准
|
||||
|
||||
V1 完成后,需要满足以下验收标准:
|
||||
|
||||
- 可以通过 Docker Compose 启动系统。
|
||||
- 首页可以看到至少 5 个预置场景。
|
||||
- 可以进入某个场景进行 Agent 对话。
|
||||
- 可以上传 TXT、Markdown、PDF 或 DOCX 文件。
|
||||
- 可以将上传文件写入本地知识库。
|
||||
- Agent 回答时可以使用知识库检索结果。
|
||||
- 至少支持 2 个内置工具调用。
|
||||
- Agent 输出可以以结构化方式展示。
|
||||
- 每次对话都会生成审计日志。
|
||||
- 审计日志中可以查看用户问题、检索内容、工具调用和最终输出。
|
||||
- 可以通过环境变量切换大模型 API 地址和模型名。
|
||||
|
||||
## 15. 复试改题策略
|
||||
|
||||
拿到题目后,优先按以下步骤适配:
|
||||
|
||||
1. 判断题目属于哪类模板。
|
||||
2. 复制最接近的 YAML 场景配置。
|
||||
3. 修改 Agent 角色、任务目标和输出模板。
|
||||
4. 上传题目给出的文档或样例数据。
|
||||
5. 如果题目需要业务计算,则新增一个工具函数。
|
||||
6. 用 2 到 3 个测试问题验证效果。
|
||||
7. 演示时重点展示配置、知识库、工具调用、结构化输出和审计日志。
|
||||
|
||||
题型映射:
|
||||
|
||||
| 题目类型 | 优先模板 |
|
||||
|---|---|
|
||||
| SOP 问答 | knowledge_qa |
|
||||
| 制度问答 | knowledge_qa |
|
||||
| 文档审核 | document_review |
|
||||
| 客服处理 | ticket_assistant |
|
||||
| 质量异常分析 | quality_analysis |
|
||||
| 财务审核 | risk_audit |
|
||||
| 采购审核 | risk_audit |
|
||||
| 合同风险分析 | document_review 或 risk_audit |
|
||||
|
||||
## 16. 后续迭代方向
|
||||
|
||||
V1 完成后,可以根据时间增加以下能力:
|
||||
|
||||
- 支持 Excel 数据分析工具。
|
||||
- 支持后台页面编辑场景配置,并同步生成或更新 YAML。
|
||||
- 支持流式输出。
|
||||
- 支持 OpenAI Agents SDK Adapter。
|
||||
- 支持 Dify API Adapter。
|
||||
- 支持 PostgreSQL 部署模式。
|
||||
- 支持简单登录认证。
|
||||
- 支持演示数据一键初始化。
|
||||
@@ -1,85 +0,0 @@
|
||||
# 模块需求文档索引
|
||||
|
||||
本文档用于汇总 Universal Agent Demo Framework V1 的模块拆分和需求文档位置。
|
||||
|
||||
## 1. 模块拆分原则
|
||||
|
||||
V1 按 6 个核心模块拆分:
|
||||
|
||||
```text
|
||||
config
|
||||
apps.scenarios
|
||||
apps.documents
|
||||
apps.chat
|
||||
apps.audit
|
||||
agent_core
|
||||
```
|
||||
|
||||
拆分原则:
|
||||
|
||||
- Django Apps 负责业务外壳。
|
||||
- Agent Core 负责 AI 能力。
|
||||
- RAG、工具调用、模型适配不直接写进 View。
|
||||
- 第一版不做复杂权限、多租户和完整工作流。
|
||||
- 模块数量保持克制,方便复试前快速改题。
|
||||
|
||||
## 2. 模块文档列表
|
||||
|
||||
| 模块 | 文档 | 说明 |
|
||||
|---|---|---|
|
||||
| 配置 | `3.配置模块需求.md` | Django 项目配置、环境变量、部署配置 |
|
||||
| 场景 | `4.场景模块需求.md` | 场景模板、场景配置、场景列表 |
|
||||
| 文档 | `5.文档模块需求.md` | 文件上传、文件管理、RAG 入库入口 |
|
||||
| 对话 | `6.对话模块需求.md` | 对话页面、Agent 调用、结果展示 |
|
||||
| 审计 | `7.审计模块需求.md` | 审计日志、检索记录、工具调用记录 |
|
||||
| 智能核心 | `8.智能核心模块需求.md` | RAG、工具、模型调用、结构化输出、编排 |
|
||||
|
||||
## 3. 模块依赖关系
|
||||
|
||||
```text
|
||||
apps.chat
|
||||
|-- depends on apps.scenarios
|
||||
|-- depends on apps.audit
|
||||
|-- calls agent_core
|
||||
|
||||
apps.documents
|
||||
|-- depends on apps.scenarios
|
||||
|-- calls agent_core.rag.ingest
|
||||
|
||||
apps.audit
|
||||
|-- stores result from apps.chat / agent_core
|
||||
|
||||
agent_core
|
||||
|-- reads scenario config object
|
||||
|-- uses Chroma
|
||||
|-- uses LLM Provider
|
||||
|-- uses Tool Registry
|
||||
```
|
||||
|
||||
## 4. 推荐开发顺序
|
||||
|
||||
建议按以下顺序开发:
|
||||
|
||||
1. Config 模块:保证项目可启动。
|
||||
2. Scenarios 模块:展示 5 个预置场景。
|
||||
3. 智能核心最小闭环:输入问题,通过 OpenAI 兼容模型接口返回结构化结果。
|
||||
4. Chat 模块:页面调用 Agent Core。
|
||||
5. Audit 模块:记录每次对话。
|
||||
6. Documents 模块:上传文档。
|
||||
7. Agent Core RAG:文档入库和检索。
|
||||
8. Agent Core 工具系统:增加内置工具。
|
||||
9. Docker:一键启动。
|
||||
|
||||
## 5. V1 完成标准
|
||||
|
||||
模块文档全部完成后,V1 的实现应满足:
|
||||
|
||||
- 系统可以启动。
|
||||
- 首页可以看到 5 个场景。
|
||||
- 可以进入场景对话。
|
||||
- 可以上传文档。
|
||||
- 可以触发 RAG 入库。
|
||||
- Agent 可以返回结构化输出。
|
||||
- 工具调用和引用来源可以展示。
|
||||
- 每次对话都有审计日志。
|
||||
- Docker Compose 可以一键启动。
|
||||
@@ -1,115 +0,0 @@
|
||||
# 配置模块需求文档
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
Config 模块是 Django 项目的基础配置模块,负责系统启动、路由装配、环境变量读取、静态资源、文件存储、数据库、日志和第三方组件配置。
|
||||
|
||||
该模块不承载业务逻辑,只负责让系统稳定启动,并为其他模块提供统一运行环境。
|
||||
|
||||
## 2. 模块目标
|
||||
|
||||
- 支持本地开发和 Docker 部署两种运行方式。
|
||||
- 支持通过环境变量切换模型 API、Embedding API、调试模式和文件路径。
|
||||
- 统一注册 Django Apps、模板目录、静态资源目录和上传目录。
|
||||
- 提供系统级 URL 路由入口。
|
||||
- 为后续扩展 PostgreSQL、Redis、Celery 等组件预留配置空间。
|
||||
|
||||
## 3. 职责边界
|
||||
|
||||
### 3.1 负责
|
||||
|
||||
- Django `settings.py` 配置。
|
||||
- Django `urls.py` 总路由配置。
|
||||
- WSGI / ASGI 启动配置。
|
||||
- 环境变量读取。
|
||||
- SQLite 默认数据库配置。
|
||||
- 静态文件和上传文件配置。
|
||||
- Chroma 本地持久化目录配置。
|
||||
- LLM 与 Embedding 相关环境变量配置。
|
||||
|
||||
### 3.2 不负责
|
||||
|
||||
- 不处理具体 Agent 业务逻辑。
|
||||
- 不解析场景 YAML。
|
||||
- 不处理文件入库。
|
||||
- 不直接调用大模型。
|
||||
- 不保存审计日志。
|
||||
|
||||
## 4. 配置项需求
|
||||
|
||||
系统至少需要支持以下环境变量:
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `DJANGO_SECRET_KEY` | `dev-secret-key` | Django 密钥 |
|
||||
| `DJANGO_DEBUG` | `true` | 是否开启调试模式 |
|
||||
| `DJANGO_ALLOWED_HOSTS` | `*` | 允许访问的主机 |
|
||||
| `DATABASE_URL` | 空 | 预留配置,V1 默认 SQLite,不要求解析该配置 |
|
||||
| `LLM_API_KEY` | 空 | 大模型 API Key |
|
||||
| `LLM_BASE_URL` | `https://api.openai.com/v1` | OpenAI 兼容接口地址,可接入 OpenAI、硅基流动等兼容服务 |
|
||||
| `LLM_MODEL` | `gpt-4.1-mini` | 默认模型名称 |
|
||||
| `EMBEDDING_API_KEY` | 空 | Embedding API Key;为空时可复用 `LLM_API_KEY` |
|
||||
| `EMBEDDING_BASE_URL` | 空 | Embedding OpenAI 兼容接口地址;为空时可复用 `LLM_BASE_URL` |
|
||||
| `EMBEDDING_MODEL` | `text-embedding-3-small` | 默认 Embedding 模型名称 |
|
||||
| `CHROMA_PATH` | `data/chroma` | Chroma 持久化目录 |
|
||||
| `UPLOAD_ROOT` | `data/uploads` | 上传文件目录 |
|
||||
| `SCENARIO_CONFIG_DIR` | `configs` | 场景配置目录 |
|
||||
|
||||
补充要求:
|
||||
|
||||
- `.env.example` 仅作为模板文件,不得写入真实密钥。
|
||||
- 本地直接执行 `python manage.py runserver` 时,应自动读取根目录 `.env`。
|
||||
- Docker 运行时可通过 `env_file` 或容器环境变量注入同一组配置;当前仓库默认由 Compose 读取 `.env`。
|
||||
|
||||
## 5. 目录需求
|
||||
|
||||
系统启动时需要保证以下目录存在:
|
||||
|
||||
```text
|
||||
data/
|
||||
uploads/
|
||||
chroma/
|
||||
db.sqlite3
|
||||
|
||||
configs/
|
||||
```
|
||||
|
||||
如果目录不存在,V1 可以在初始化脚本或启动流程中创建。
|
||||
|
||||
## 6. 路由需求
|
||||
|
||||
总路由需要聚合以下模块路由:
|
||||
|
||||
| 路径 | 模块 | 用途 |
|
||||
|---|---|---|
|
||||
| `/` | `apps.scenarios` | 首页和场景列表 |
|
||||
| `/chat/` | `apps.chat` | Agent 对话 |
|
||||
| `/documents/` | `apps.documents` | 文件上传和文档管理 |
|
||||
| `/audit/` | `apps.audit` | 审计日志查看 |
|
||||
| `/admin/` | Django Admin | 后台管理 |
|
||||
|
||||
## 7. 启动需求
|
||||
|
||||
本地启动:
|
||||
|
||||
```bash
|
||||
python manage.py migrate
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
说明:上述命令执行前,应先准备好根目录 `.env`;当前 V1 代码会在启动时自动加载该文件。
|
||||
|
||||
Docker 启动:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- 项目可以通过 `python manage.py runserver` 启动。
|
||||
- 项目可以通过 `docker compose up --build` 启动。
|
||||
- `/admin/` 可以访问。
|
||||
- 首页 `/` 可以访问。
|
||||
- 环境变量可以覆盖默认 LLM 与 Embedding 配置。
|
||||
- 上传目录和 Chroma 目录有明确配置。
|
||||
@@ -1,143 +0,0 @@
|
||||
# 场景模块需求文档
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
Scenarios 模块负责管理业务 Agent 场景,是整个平台快速适配未知复试题的核心入口。
|
||||
|
||||
场景定义需要尽量配置化,避免把具体业务逻辑写死在 Django View 或 Agent Core 中。
|
||||
|
||||
## 2. 模块目标
|
||||
|
||||
- 读取预置场景配置。
|
||||
- 展示可用业务 Agent 列表。
|
||||
- 提供场景详情。
|
||||
- 为 Chat 模块提供当前场景的完整配置。
|
||||
- 以 YAML 配置文件作为 V1 场景唯一事实来源。
|
||||
|
||||
## 3. 职责边界
|
||||
|
||||
### 3.1 负责
|
||||
|
||||
- 场景模板定义。
|
||||
- 场景配置文件读取。
|
||||
- 场景元信息展示。
|
||||
- 场景启用/禁用状态。
|
||||
- 场景与文档、审计日志的关联关系。
|
||||
|
||||
### 3.2 不负责
|
||||
|
||||
- 不执行 Agent 对话。
|
||||
- 不直接处理 RAG 检索。
|
||||
- 不直接调用工具。
|
||||
- 不直接调用大模型。
|
||||
- 不解析结构化输出。
|
||||
|
||||
## 4. 场景模板需求
|
||||
|
||||
V1 预置 5 类场景模板:
|
||||
|
||||
| 模板 ID | 模板名称 | 适用题型 |
|
||||
|---|---|---|
|
||||
| `knowledge_qa` | 知识库问答助手 | SOP、制度、客服知识库、内部文档问答 |
|
||||
| `document_review` | 文档审核助手 | 合同审核、制度审核、材料合规检查 |
|
||||
| `ticket_assistant` | 工单处理助手 | 客服工单、售后工单、运维工单 |
|
||||
| `quality_analysis` | 质量异常分析助手 | 生产质量、缺陷分析、原因定位 |
|
||||
| `risk_audit` | 风险审核助手 | 财务审核、采购审核、报销审核、合同风险 |
|
||||
|
||||
## 5. 场景配置字段
|
||||
|
||||
场景配置文件使用 YAML。V1 的后台管理只管理上传文件、审计日志和示例业务数据等外围数据,不作为场景配置入口。
|
||||
|
||||
必填字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | string | 场景唯一标识 |
|
||||
| `name` | string | 场景名称 |
|
||||
| `description` | string | 场景说明 |
|
||||
| `agent.role` | string | Agent 角色 |
|
||||
| `agent.goal` | string | Agent 目标 |
|
||||
| `agent.instructions` | list[string] | Agent 指令 |
|
||||
| `agent.system_prompt` | string | 可选字段;配置后优先作为系统提示词 |
|
||||
| `rag.enabled` | boolean | 是否启用 RAG |
|
||||
| `tools` | list[string] | 可用工具列表 |
|
||||
| `output.type` | string | 输出模板类型 |
|
||||
| `audit.enabled` | boolean | 是否记录审计 |
|
||||
|
||||
示例:
|
||||
|
||||
```yaml
|
||||
id: document_review
|
||||
name: 文档审核助手
|
||||
description: 检查合同、制度或 SOP 中的风险点和缺失项
|
||||
|
||||
agent:
|
||||
role: 文档审核专家
|
||||
goal: 根据审核规则和知识库内容输出结构化审核意见
|
||||
system_prompt: ""
|
||||
instructions:
|
||||
- 只基于用户提供文档和知识库进行判断
|
||||
- 不确定的问题必须标记为需人工复核
|
||||
- 输出必须包含风险等级和修改建议
|
||||
|
||||
rag:
|
||||
enabled: true
|
||||
collection: document_review
|
||||
top_k: 5
|
||||
|
||||
tools:
|
||||
- check_required_fields
|
||||
|
||||
output:
|
||||
type: document_review_report
|
||||
|
||||
audit:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
## 6. 页面需求
|
||||
|
||||
### 6.1 场景列表页
|
||||
|
||||
路径:`/`
|
||||
|
||||
展示内容:
|
||||
|
||||
- 场景名称。
|
||||
- 场景描述。
|
||||
- 适用题型。
|
||||
- 是否启用。
|
||||
- 进入对话按钮。
|
||||
|
||||
### 6.2 场景详情页 可选
|
||||
|
||||
路径:`/scenarios/<scenario_id>/`
|
||||
|
||||
展示内容:
|
||||
|
||||
- Agent 角色。
|
||||
- Agent 目标。
|
||||
- RAG 是否启用。
|
||||
- 可用工具列表。
|
||||
- 输出模板类型。
|
||||
|
||||
V1 可以不做独立详情页,在对话页展示当前场景摘要即可。
|
||||
|
||||
## 7. 服务接口需求
|
||||
|
||||
Scenarios 模块至少需要提供以下服务函数:
|
||||
|
||||
```text
|
||||
list_scenarios() -> list[ScenarioConfig]
|
||||
get_scenario(scenario_id: str) -> ScenarioConfig
|
||||
validate_scenario(config: dict) -> ValidationResult
|
||||
```
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- 首页可以展示 5 个预置场景。
|
||||
- 点击场景可以进入对应对话页。
|
||||
- 场景配置来自配置文件,而不是硬编码在 View 中。
|
||||
- 后台管理不作为 V1 场景配置编辑入口。
|
||||
- 缺失必填字段时能给出明确错误。
|
||||
- Chat 模块可以根据 `scenario_id` 获取完整场景配置。
|
||||
@@ -1,132 +0,0 @@
|
||||
# 文档模块需求文档
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
Documents 模块负责文件上传、文件管理、文本抽取和知识库入库入口。
|
||||
|
||||
该模块是复试题快速适配的关键模块。拿到题目材料后,用户需要能快速上传文档,并让 Agent 在对话中使用这些文档。
|
||||
|
||||
## 2. 模块目标
|
||||
|
||||
- 支持上传题目材料和知识库文件。
|
||||
- 保存文件元数据。
|
||||
- 支持按场景关联文件。
|
||||
- 提供文档入库入口。
|
||||
- 为 Agent Core 的 RAG 模块提供文件内容。
|
||||
|
||||
## 3. 职责边界
|
||||
|
||||
### 3.1 负责
|
||||
|
||||
- 文件上传页面。
|
||||
- 文件保存。
|
||||
- 文件元数据记录。
|
||||
- 文件与场景关联。
|
||||
- 文本抽取入口。
|
||||
- 触发 RAG 入库。
|
||||
|
||||
### 3.2 不负责
|
||||
|
||||
- 不负责具体向量检索算法。
|
||||
- 不负责 embedding 生成细节。
|
||||
- 不负责 Agent 对话编排。
|
||||
- 不负责模型回答。
|
||||
|
||||
## 4. 支持文件类型
|
||||
|
||||
V1 必须支持:
|
||||
|
||||
| 类型 | 扩展名 | 说明 |
|
||||
|---|---|---|
|
||||
| 文本文档 | `.txt` | 第一优先级,最稳定 |
|
||||
| Markdown | `.md` | 适合准备知识库和规则 |
|
||||
| PDF | `.pdf` | 复试常见材料格式,V1 抽取纯文本 |
|
||||
| Word | `.docx` | 复试常见材料格式,V1 抽取段落文本 |
|
||||
|
||||
后续增强:
|
||||
|
||||
| 类型 | 扩展名 | 说明 |
|
||||
|---|---|---|
|
||||
| Excel | `.xlsx` | 后续可作为业务数据源或结构化表格导入 |
|
||||
|
||||
## 5. 数据模型需求
|
||||
|
||||
建议模型:`UploadedDocument`
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 主键 |
|
||||
| `scenario_id` | string | 关联场景 ID |
|
||||
| `original_name` | string | 原始文件名 |
|
||||
| `file` | FileField | Django FileField 相对路径,不保存用户本机绝对路径 |
|
||||
| `file_type` | string | 文件类型 |
|
||||
| `size` | int | 文件大小 |
|
||||
| `status` | string | `uploaded` / `indexed` / `failed` |
|
||||
| `error_message` | text | 入库失败原因 |
|
||||
| `created_at` | datetime | 上传时间 |
|
||||
| `updated_at` | datetime | 更新时间 |
|
||||
|
||||
## 6. 页面需求
|
||||
|
||||
### 6.1 文件上传页
|
||||
|
||||
路径:`/documents/upload/`
|
||||
|
||||
页面元素:
|
||||
|
||||
- 场景选择下拉框。
|
||||
- 文件选择按钮。
|
||||
- 上传按钮。
|
||||
- 支持类型提示。
|
||||
- 上传结果提示。
|
||||
|
||||
### 6.2 文件列表页
|
||||
|
||||
路径:`/documents/`
|
||||
|
||||
展示内容:
|
||||
|
||||
- 文件名。
|
||||
- 所属场景。
|
||||
- 文件类型。
|
||||
- 文件大小。
|
||||
- 入库状态。
|
||||
- 上传时间。
|
||||
- 入库按钮。
|
||||
|
||||
## 7. RAG 入库流程
|
||||
|
||||
用户上传文件后,可以手动触发入库。
|
||||
|
||||
流程:
|
||||
|
||||
1. 用户上传文件。
|
||||
2. 系统保存文件和元数据。
|
||||
3. 用户点击入库按钮。
|
||||
4. Documents 模块读取文件文本。
|
||||
5. 调用 `agent_core.rag.ingest`。
|
||||
6. 入库成功后更新状态为 `indexed`。
|
||||
7. 入库失败后更新状态为 `failed` 并保存错误信息。
|
||||
|
||||
## 8. 文本抽取需求
|
||||
|
||||
V1 文本抽取策略:
|
||||
|
||||
- `.txt`:按 UTF-8 读取,失败时尝试系统默认编码。
|
||||
- `.md`:按 UTF-8 读取,保留标题和正文。
|
||||
- `.pdf`:抽取纯文本,不要求 OCR、表格还原和复杂版式理解。
|
||||
- `.docx`:抽取段落、标题和普通表格文本,不要求完整保留 Word 样式。
|
||||
|
||||
入库失败后的文档允许重新触发入库。重新入库前需要清理或覆盖同一 `document_id` 对应的旧 chunk,避免重复检索。
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- 可以上传 `.txt`、`.md`、`.pdf`、`.docx` 文件。
|
||||
- 上传后可以在文件列表看到记录。
|
||||
- 文件可以关联到指定场景。
|
||||
- 可以触发文件入库。
|
||||
- 入库成功后状态变为 `indexed`。
|
||||
- 入库失败时页面能显示失败原因。
|
||||
- 入库失败的文档可以重新入库。
|
||||
@@ -1,129 +0,0 @@
|
||||
# 对话模块需求文档
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
Chat 模块负责 Agent 对话页面和用户交互,是复试演示时最核心的入口。
|
||||
|
||||
该模块接收用户问题,加载场景配置,调用 Agent Core 执行智能编排,并将结构化结果、引用来源、工具调用和审计信息展示给用户。
|
||||
|
||||
## 2. 模块目标
|
||||
|
||||
- 提供按场景进入的 Agent 对话页。
|
||||
- 支持用户输入业务问题。
|
||||
- 调用 Agent Core 执行完整 Agent 流程。
|
||||
- 展示结构化输出。
|
||||
- 展示 RAG 引用片段。
|
||||
- 展示工具调用记录。
|
||||
- 触发审计日志写入。
|
||||
|
||||
## 3. 职责边界
|
||||
|
||||
### 3.1 负责
|
||||
|
||||
- 对话页面渲染。
|
||||
- 表单接收和校验。
|
||||
- 当前场景上下文传递。
|
||||
- 调用 Agent Core。
|
||||
- 展示 Agent 返回结果。
|
||||
|
||||
### 3.2 不负责
|
||||
|
||||
- 不直接读取 YAML 场景文件。
|
||||
- 不直接执行 RAG 检索。
|
||||
- 不直接执行工具函数。
|
||||
- 不直接调用大模型 API。
|
||||
- 不直接写复杂审计细节。
|
||||
|
||||
## 4. 页面需求
|
||||
|
||||
### 4.1 Agent 对话页
|
||||
|
||||
路径:`/chat/<scenario_id>/`
|
||||
|
||||
页面区域:
|
||||
|
||||
- 当前场景摘要。
|
||||
- 当前场景下已入库文档多选框。
|
||||
- 用户问题输入框。
|
||||
- 提交按钮。
|
||||
- Agent 结构化输出区域。
|
||||
- 引用来源区域。
|
||||
- 工具调用区域。
|
||||
- 执行耗时区域。
|
||||
- 审计日志详情入口。
|
||||
|
||||
## 5. 表单需求
|
||||
|
||||
用户输入表单字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `message` | textarea | 是 | 用户业务问题 |
|
||||
| `document_ids` | list[int] | 否 | 本次对话指定使用的已入库文档 |
|
||||
|
||||
校验规则:
|
||||
|
||||
- 输入不能为空。
|
||||
- 输入长度建议不超过 4000 字。
|
||||
- 如果场景不存在,需要返回明确错误。
|
||||
- `document_ids` 只能包含当前场景下状态为 `indexed` 的文档。
|
||||
- 未选择文档时,默认使用当前场景下全部已入库文档作为 RAG 范围。
|
||||
|
||||
## 6. Agent 执行流程
|
||||
|
||||
Chat 模块调用 Agent Core 的流程:
|
||||
|
||||
```text
|
||||
用户提交问题
|
||||
↓
|
||||
校验 scenario_id 和 message
|
||||
↓
|
||||
获取场景配置
|
||||
↓
|
||||
调用 `run_agent(scenario_config, user_input, options=None)`
|
||||
↓
|
||||
获取 AgentResult
|
||||
↓
|
||||
调用 Audit 模块记录日志
|
||||
↓
|
||||
渲染结果页面
|
||||
```
|
||||
|
||||
## 7. AgentResult 展示需求
|
||||
|
||||
Agent Core 返回结果建议包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `answer` | 自然语言回答 |
|
||||
| `structured_output` | 结构化结果 |
|
||||
| `references` | RAG 引用来源 |
|
||||
| `tool_calls` | 工具调用记录 |
|
||||
| `raw_output` | 模型原始输出 |
|
||||
| `latency_ms` | 执行耗时 |
|
||||
| `error` | 错误信息 |
|
||||
|
||||
页面需要优先展示结构化结果。如果结构化解析失败,则展示自然语言回答和错误提示。
|
||||
|
||||
## 8. 错误处理需求
|
||||
|
||||
需要处理以下错误:
|
||||
|
||||
| 错误 | 页面行为 |
|
||||
|---|---|
|
||||
| 场景不存在 | 显示场景不存在 |
|
||||
| 用户输入为空 | 显示表单错误 |
|
||||
| LLM API Key 缺失 | 显示模型配置缺失 |
|
||||
| RAG 检索失败 | 显示检索失败,但允许模型基于已有信息回答 |
|
||||
| 工具调用失败 | 显示工具失败信息,并继续生成结果 |
|
||||
| 结构化解析失败 | 展示原始回答,并提示结构化解析失败 |
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- 可以从场景列表进入对话页。
|
||||
- 可以提交问题并获得 Agent 输出。
|
||||
- 页面能展示结构化结果。
|
||||
- 页面能展示引用来源。
|
||||
- 页面能展示工具调用记录。
|
||||
- 执行失败时有可理解的错误提示。
|
||||
- 每次对话都会产生审计日志。
|
||||
@@ -1,143 +0,0 @@
|
||||
# Audit 模块需求文档
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
Audit 模块负责记录和展示 Agent 执行过程,是项目体现企业级能力的重要模块。
|
||||
|
||||
复试演示时,审计日志用于证明系统不是黑盒问答,而是可以追踪输入、检索、工具调用、模型输出和执行耗时。
|
||||
|
||||
## 2. 模块目标
|
||||
|
||||
- 记录每次 Agent 对话。
|
||||
- 记录 RAG 检索片段。
|
||||
- 记录工具调用详情。
|
||||
- 记录模型输出和结构化结果。
|
||||
- 提供审计日志列表和详情页。
|
||||
- 支持按场景查看日志。
|
||||
|
||||
## 3. 职责边界
|
||||
|
||||
### 3.1 负责
|
||||
|
||||
- 审计日志数据模型。
|
||||
- 日志写入服务。
|
||||
- 日志列表页面。
|
||||
- 日志详情页面。
|
||||
- 工具调用记录展示。
|
||||
- RAG 引用记录展示。
|
||||
|
||||
### 3.2 不负责
|
||||
|
||||
- 不执行 Agent。
|
||||
- 不执行工具调用。
|
||||
- 不执行 RAG 检索。
|
||||
- 不参与模型生成。
|
||||
- 不做复杂权限控制。
|
||||
|
||||
## 4. 数据模型需求
|
||||
|
||||
建议模型:`AgentAuditLog`
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | 主键 |
|
||||
| `scenario_id` | string | 场景 ID |
|
||||
| `scenario_name` | string | 场景名称 |
|
||||
| `user_input` | text | 用户输入 |
|
||||
| `retrieved_chunks` | JSON | 检索片段 |
|
||||
| `tool_calls` | JSON | 工具调用记录 |
|
||||
| `structured_output` | JSON | 结构化输出 |
|
||||
| `final_answer` | text | 最终回答 |
|
||||
| `raw_output` | text | 模型原始输出 |
|
||||
| `model_name` | string | 模型名称 |
|
||||
| `latency_ms` | int | 执行耗时 |
|
||||
| `status` | string | `success` / `failed` |
|
||||
| `error_message` | text | 错误信息 |
|
||||
| `created_at` | datetime | 创建时间 |
|
||||
|
||||
## 5. 日志写入需求
|
||||
|
||||
Audit 模块需要提供服务函数:
|
||||
|
||||
```text
|
||||
create_audit_log(
|
||||
scenario_id,
|
||||
scenario_name,
|
||||
user_input,
|
||||
agent_result
|
||||
) -> AgentAuditLog
|
||||
```
|
||||
|
||||
写入规则:
|
||||
|
||||
- Agent 成功时,记录完整结果。
|
||||
- Agent 失败时,也要记录用户输入、场景和错误信息。
|
||||
- RAG 片段和工具调用使用 JSON 保存。
|
||||
- 不记录 API Key 等敏感配置。
|
||||
|
||||
## 6. 页面需求
|
||||
|
||||
### 6.1 审计日志列表页
|
||||
|
||||
路径:`/audit/`
|
||||
|
||||
展示字段:
|
||||
|
||||
- 日志 ID。
|
||||
- 场景名称。
|
||||
- 用户输入摘要。
|
||||
- 状态。
|
||||
- 模型名称。
|
||||
- 执行耗时。
|
||||
- 创建时间。
|
||||
- 详情入口。
|
||||
|
||||
### 6.2 审计日志详情页
|
||||
|
||||
路径:`/audit/<log_id>/`
|
||||
|
||||
展示内容:
|
||||
|
||||
- 用户输入。
|
||||
- 最终回答。
|
||||
- 结构化输出。
|
||||
- RAG 检索片段。
|
||||
- 工具调用记录。
|
||||
- 模型名称。
|
||||
- 执行耗时。
|
||||
- 错误信息。
|
||||
|
||||
## 7. 检索片段展示需求
|
||||
|
||||
每个引用片段建议包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `source` | 来源文件名 |
|
||||
| `chunk_id` | 片段 ID |
|
||||
| `content` | 片段内容 |
|
||||
| `score` | 相似度分数 |
|
||||
|
||||
## 8. 工具调用展示需求
|
||||
|
||||
每次工具调用建议包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `tool_name` | 工具名称 |
|
||||
| `arguments` | 调用参数 |
|
||||
| `result` | 工具结果 |
|
||||
| `success` | 是否成功 |
|
||||
| `error` | 错误信息 |
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- 每次对话成功后都会生成审计日志。
|
||||
- Agent 执行失败时也会生成失败日志。
|
||||
- 审计列表可以查看所有日志。
|
||||
- 审计详情可以查看用户输入、检索片段、工具调用和最终输出。
|
||||
- 日志中不保存 API Key。
|
||||
- 可以根据日志解释一次 Agent 输出的依据。
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
# 智能核心模块需求文档
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
Agent Core 是系统的智能能力核心,负责根据场景配置完成 RAG 检索、工具调用、大模型调用和结构化输出。
|
||||
|
||||
该模块应保持独立于 Django View,方便后续迁移为独立服务,或接入 OpenAI Agents SDK、Dify 等外部编排引擎。
|
||||
|
||||
## 2. 模块目标
|
||||
|
||||
- 提供统一 Agent 执行入口。
|
||||
- 根据场景配置组织 Prompt。
|
||||
- 支持 RAG 检索。
|
||||
- 支持工具注册与调用。
|
||||
- 支持 OpenAI API 兼容的 LLM 与 Embedding 调用,可自主接入 OpenAI、硅基流动等兼容服务。
|
||||
- 支持结构化输出解析。
|
||||
- 返回可审计的 AgentResult。
|
||||
|
||||
## 3. 职责边界
|
||||
|
||||
### 3.1 负责
|
||||
|
||||
- Agent 编排。
|
||||
- 场景配置对象消费。
|
||||
- RAG 入库和检索核心逻辑。
|
||||
- 工具注册和工具执行。
|
||||
- LLM Provider 适配。
|
||||
- 输出结构化解析。
|
||||
- 生成 AgentResult。
|
||||
|
||||
### 3.2 不负责
|
||||
|
||||
- 不渲染页面。
|
||||
- 不直接处理 Django 表单。
|
||||
- 不直接保存 Django Model。
|
||||
- 不管理用户登录。
|
||||
- 不负责 Docker 部署。
|
||||
|
||||
## 4. 子模块划分
|
||||
|
||||
```text
|
||||
agent_core/
|
||||
orchestrator.py
|
||||
scenario_loader.py
|
||||
llm_provider.py
|
||||
tool_registry.py
|
||||
structured_output.py
|
||||
|
||||
rag/
|
||||
ingest.py
|
||||
retriever.py
|
||||
|
||||
tools/
|
||||
builtin_tools.py
|
||||
|
||||
schemas/
|
||||
outputs.py
|
||||
```
|
||||
|
||||
## 5. Orchestrator 需求
|
||||
|
||||
`orchestrator.py` 提供统一入口:
|
||||
|
||||
```text
|
||||
run_agent(
|
||||
scenario_config,
|
||||
user_input,
|
||||
options=None
|
||||
) -> AgentResult
|
||||
```
|
||||
|
||||
执行流程:
|
||||
|
||||
1. 读取场景配置。
|
||||
2. 根据配置判断是否启用 RAG。
|
||||
3. 按 `scenario_id` 和可选 `document_ids` 检索相关知识片段。
|
||||
4. 根据配置加载可用工具。
|
||||
5. 构造系统提示词。
|
||||
6. 调用大模型。
|
||||
7. 执行必要的工具调用。
|
||||
8. 解析结构化输出。
|
||||
9. 返回 AgentResult。
|
||||
|
||||
V1 可以使用轻量 Orchestrator,不强制引入完整 Agent SDK。
|
||||
|
||||
## 6. RAG 需求
|
||||
|
||||
### 6.1 入库
|
||||
|
||||
`rag/ingest.py` 负责:
|
||||
|
||||
- 接收文档文本。
|
||||
- 文本切分。
|
||||
- 通过 OpenAI 兼容 Embedding Provider 生成 embedding。
|
||||
- 写入 Chroma。
|
||||
- 保存 metadata。
|
||||
|
||||
metadata 至少包含:
|
||||
|
||||
- `scenario_id`
|
||||
- `document_id`
|
||||
- `source_file`
|
||||
- `chunk_id`
|
||||
- `created_at`
|
||||
|
||||
### 6.2 检索
|
||||
|
||||
`rag/retriever.py` 负责:
|
||||
|
||||
- 根据用户问题检索相关片段。
|
||||
- 支持按 `scenario_id` 过滤。
|
||||
- 支持按本次对话选择的 `document_ids` 过滤;未选择时使用当前场景全部已入库文档。
|
||||
- 返回 top_k 结果。
|
||||
- 返回内容、来源和分数。
|
||||
|
||||
## 7. 工具系统需求
|
||||
|
||||
`tool_registry.py` 负责工具注册、查找和执行。
|
||||
|
||||
V1 内置工具:
|
||||
|
||||
| 工具名 | 说明 |
|
||||
|---|---|
|
||||
| `calculate_rate` | 计算通过率、缺陷率、占比等 |
|
||||
| `query_demo_records` | 查询模拟业务数据 |
|
||||
| `check_required_fields` | 检查必填项是否缺失 |
|
||||
| `generate_action_items` | 生成行动项清单 |
|
||||
|
||||
工具执行结果需要统一格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "calculate_rate",
|
||||
"success": true,
|
||||
"arguments": {},
|
||||
"result": {},
|
||||
"error": ""
|
||||
}
|
||||
```
|
||||
|
||||
## 8. LLM Provider 需求
|
||||
|
||||
`llm_provider.py` 负责模型调用。
|
||||
|
||||
V1 需要支持 OpenAI API 兼容 LLM 接口:
|
||||
|
||||
- `LLM_API_KEY`
|
||||
- `LLM_BASE_URL`
|
||||
- `LLM_MODEL`
|
||||
|
||||
接口需要隐藏不同模型供应商差异,对 Orchestrator 暴露统一方法:
|
||||
|
||||
```text
|
||||
generate(messages, response_format=None) -> LLMResponse
|
||||
```
|
||||
|
||||
Embedding 也通过 OpenAI 兼容接口接入:
|
||||
|
||||
- `EMBEDDING_API_KEY`
|
||||
- `EMBEDDING_BASE_URL`
|
||||
- `EMBEDDING_MODEL`
|
||||
|
||||
当 Embedding 专用 Key 或 Base URL 为空时,可以复用 LLM 的 Key 和 Base URL。RAG 入库和检索必须通过真实 embedding 与 Chroma 完成,模拟 embedding 或简单文本匹配只能作为开发阶段临时桩,不计入 V1 验收。
|
||||
|
||||
## 9. 结构化输出需求
|
||||
|
||||
`structured_output.py` 负责将模型输出转换为业务结构。
|
||||
|
||||
V1 输出类型:
|
||||
|
||||
- `general_answer`
|
||||
- `document_review_report`
|
||||
- `ticket_response`
|
||||
- `quality_report`
|
||||
- `risk_audit_report`
|
||||
|
||||
解析策略:
|
||||
|
||||
- 优先要求模型直接返回 JSON。
|
||||
- JSON 解析成功则展示结构化结果。
|
||||
- JSON 解析失败则保留原始输出,并返回解析错误。
|
||||
|
||||
## 10. AgentResult 需求
|
||||
|
||||
Agent Core 最终返回统一结果对象。
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `answer` | 最终自然语言回答 |
|
||||
| `structured_output` | 结构化输出 |
|
||||
| `references` | RAG 引用片段 |
|
||||
| `tool_calls` | 工具调用记录 |
|
||||
| `raw_output` | 模型原始输出 |
|
||||
| `model_name` | 模型名称 |
|
||||
| `latency_ms` | 执行耗时 |
|
||||
| `status` | `success` / `failed` |
|
||||
| `error` | 错误信息 |
|
||||
|
||||
## 11. Adapter 扩展需求
|
||||
|
||||
V1 默认使用 `LightweightOrchestrator`。
|
||||
|
||||
后续可扩展:
|
||||
|
||||
- OpenAI Agents SDK Adapter。
|
||||
- Dify API Adapter。
|
||||
- LangGraph Adapter。
|
||||
|
||||
Adapter 需要保持同样输入输出:
|
||||
|
||||
```text
|
||||
run_agent(scenario_config, user_input, options=None) -> AgentResult
|
||||
```
|
||||
|
||||
## 12. 验收标准
|
||||
|
||||
- Chat 模块可以调用 Agent Core 获得统一 AgentResult。
|
||||
- RAG 可以按场景检索知识片段。
|
||||
- RAG 可以按本次对话选择的文档范围检索知识片段。
|
||||
- 工具调用结果可以记录并返回。
|
||||
- LLM 与 Embedding 配置可以通过环境变量切换。
|
||||
- 结构化输出解析失败时不会导致整个流程崩溃。
|
||||
- Agent Core 不依赖 Django View。
|
||||
@@ -1,61 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}审计日志详情{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<span class="eyebrow">日志详情</span>
|
||||
<h1 class="page-title">审计日志 #{{ log.id }}</h1>
|
||||
<p class="page-lead">这里集中展示当前请求的输入、模型输出、知识库引用和工具调用记录。</p>
|
||||
<p style="margin-top: 14px;"><a class="button" href="{% url 'audit:list' %}">返回审计列表</a></p>
|
||||
</header>
|
||||
|
||||
<section class="stack">
|
||||
<article class="panel">
|
||||
<h2 class="section-title">基础信息</h2>
|
||||
<ul class="meta-list">
|
||||
<li class="meta-badge">场景:{{ log.scenario_name }}</li>
|
||||
<li class="meta-badge">状态:{{ log.get_status_display_text }}</li>
|
||||
<li class="meta-badge">模型:{{ log.model_name }}</li>
|
||||
<li class="meta-badge">耗时:{{ log.latency_ms }} ms</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">用户输入</h2>
|
||||
<div class="detail-item">{{ log.user_input|linebreaksbr }}</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">最终回答</h2>
|
||||
<div class="detail-item">{{ log.final_answer|linebreaksbr }}</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">结构化输出</h2>
|
||||
<pre class="code-block">{{ log.structured_output }}</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">引用来源</h2>
|
||||
<pre class="code-block">{{ log.retrieved_chunks }}</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">工具调用</h2>
|
||||
<pre class="code-block">{{ log.tool_calls }}</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">原始输出</h2>
|
||||
<pre class="code-block">{{ log.raw_output }}</pre>
|
||||
</article>
|
||||
|
||||
{% if log.error_message %}
|
||||
<article class="panel">
|
||||
<h2 class="section-title">错误信息</h2>
|
||||
<pre class="code-block">{{ log.error_message }}</pre>
|
||||
</article>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,50 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}审计日志{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<span class="eyebrow">执行留痕</span>
|
||||
<h1 class="page-title">审计日志</h1>
|
||||
<p class="page-lead">每次 Agent 执行都会记录模型、检索片段、工具调用和最终结果,方便演示链路可解释性。</p>
|
||||
{% if selected_scenario_id %}
|
||||
<p style="margin-top: 14px;">
|
||||
<span class="meta-badge">当前筛选场景:{{ selected_scenario_id }}</span>
|
||||
<a class="button" href="{% url 'audit:list' %}">清空筛选</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<article class="panel">
|
||||
<table class="kv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>场景</th>
|
||||
<th>输入摘要</th>
|
||||
<th>状态</th>
|
||||
<th>模型</th>
|
||||
<th>耗时</th>
|
||||
<th>创建时间</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.id }}</td>
|
||||
<td>{{ log.scenario_name }}</td>
|
||||
<td>{{ log.get_user_input_summary }}</td>
|
||||
<td>{{ log.get_status_display_text }}</td>
|
||||
<td>{{ log.model_name }}</td>
|
||||
<td>{{ log.latency_ms }} ms</td>
|
||||
<td>{{ log.created_at|date:"Y-m-d H:i" }}</td>
|
||||
<td><a class="button" href="{% url 'audit:detail' log.id %}">查看详情</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="8">暂无审计日志,先去执行一次对话吧。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -1,169 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ scenario.name|default:"Agent 对话" }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if error %}
|
||||
<section class="notice notice-error">{{ error }}</section>
|
||||
{% endif %}
|
||||
|
||||
{% if scenario %}
|
||||
<header class="page-header">
|
||||
<span class="eyebrow">Agent 对话</span>
|
||||
<h1 class="page-title">{{ scenario.name }}</h1>
|
||||
<p class="page-lead">{{ scenario.description }}</p>
|
||||
<ul class="meta-list">
|
||||
<li class="meta-badge">角色:{{ scenario.agent.role }}</li>
|
||||
<li class="meta-badge">目标:{{ scenario.agent.goal }}</li>
|
||||
<li class="meta-badge">已入库文档:{{ document_count }}</li>
|
||||
<li class="meta-badge">输出类型:{{ scenario.output.type }}</li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<section class="layout-two-columns">
|
||||
<div class="stack">
|
||||
<article class="panel">
|
||||
<h2 class="section-title">提问面板</h2>
|
||||
<p class="muted">可以直接提问,也可以勾选部分已入库文档作为当前上下文范围。</p>
|
||||
<form method="post" class="stack">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
{{ form.message.label_tag }}
|
||||
{{ form.message }}
|
||||
{% if form.message.errors %}
|
||||
<p class="notice notice-error">{{ form.message.errors|join:" " }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.document_ids.label_tag }}
|
||||
<p class="help-text">不勾选时默认检索当前场景全部已入库文档。</p>
|
||||
<div class="checkbox-list">
|
||||
{% for checkbox in form.document_ids %}
|
||||
<label class="checkbox-item">
|
||||
{{ checkbox.tag }}
|
||||
<span>{{ checkbox.choice_label }}</span>
|
||||
</label>
|
||||
{% empty %}
|
||||
<div class="notice">当前场景还没有已入库文档,系统将仅依赖工具和模型能力生成结果。</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.document_ids.errors %}
|
||||
<p class="notice notice-error">{{ form.document_ids.errors|join:" " }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit">提交问题并执行 Agent</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">执行说明</h2>
|
||||
<ul class="detail-list">
|
||||
<li class="detail-item">
|
||||
<strong>1. 场景配置</strong>
|
||||
系统会先读取当前 YAML 场景配置,确定角色、目标、工具和输出结构。
|
||||
</li>
|
||||
<li class="detail-item">
|
||||
<strong>2. RAG 与工具</strong>
|
||||
如果场景启用了知识库检索,系统会根据你的问题召回相关片段,并执行声明式工具。
|
||||
</li>
|
||||
<li class="detail-item">
|
||||
<strong>3. 结构化结果</strong>
|
||||
Agent Core 会优先解析 JSON 输出,解析失败时回退为稳定的展示结构。
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<article class="panel">
|
||||
<h2 class="section-title">回答总览</h2>
|
||||
{% if result %}
|
||||
<ul class="meta-list">
|
||||
<li class="meta-badge">模型:{{ result.model_name }}</li>
|
||||
<li class="meta-badge {% if result.status == 'success' %}status-success{% else %}status-failed{% endif %}">状态:{{ result.status }}</li>
|
||||
<li class="meta-badge">耗时:{{ result.latency_ms }} ms</li>
|
||||
</ul>
|
||||
<div class="detail-item" style="margin-top: 16px;">
|
||||
<strong>主回答</strong>
|
||||
<div>{{ result.answer|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% if audit_log %}
|
||||
<p style="margin-top: 14px;">
|
||||
<a class="button" href="{% url 'audit:detail' audit_log.id %}">查看本次审计日志</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="notice">提交问题后,这里会展示 Agent 的主回答、模型信息和执行状态。</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
{% if result %}
|
||||
<article class="panel">
|
||||
<h2 class="section-title">结构化结果</h2>
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{% for key, value in result.structured_output.items %}
|
||||
<tr>
|
||||
<th>{{ key }}</th>
|
||||
<td>
|
||||
{% if key == "answer" or key == "summary" or key == "reply" %}
|
||||
{{ value|linebreaksbr }}
|
||||
{% else %}
|
||||
<pre class="code-block">{{ value }}</pre>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">引用片段</h2>
|
||||
{% if result.references %}
|
||||
<ul class="detail-list">
|
||||
{% for reference in result.references %}
|
||||
<li class="detail-item">
|
||||
<strong>{{ reference.source }}</strong>
|
||||
<div>{{ reference.content|default:"无正文内容"|linebreaksbr }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="notice">当前回答没有引用知识库片段。</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2 class="section-title">工具调用</h2>
|
||||
{% if result.tool_calls %}
|
||||
<ul class="detail-list">
|
||||
{% for tool_call in result.tool_calls %}
|
||||
<li class="detail-item">
|
||||
<strong>{{ tool_call.tool_name }}</strong>
|
||||
<p class="muted">执行状态:{{ tool_call.success }}</p>
|
||||
{% if tool_call.error %}
|
||||
<p class="notice notice-error">{{ tool_call.error }}</p>
|
||||
{% endif %}
|
||||
<pre class="code-block">{{ tool_call.result }}</pre>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="notice">当前场景没有声明工具,或本次执行无需调用工具。</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
{% if result.error %}
|
||||
<article class="panel">
|
||||
<h2 class="section-title">错误信息</h2>
|
||||
<pre class="code-block">{{ result.error }}</pre>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,54 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}文档中心{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<span class="eyebrow">知识库资料</span>
|
||||
<h1 class="page-title">文档中心</h1>
|
||||
<p class="page-lead">上传题目材料后,可以在这里管理文件状态,并手动触发入库。</p>
|
||||
<p style="margin-top: 14px;"><a class="button button-primary" href="{% url 'documents:upload' %}">上传新文件</a></p>
|
||||
</header>
|
||||
|
||||
<article class="panel">
|
||||
<table class="kv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th>
|
||||
<th>场景</th>
|
||||
<th>类型</th>
|
||||
<th>大小</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for document in documents %}
|
||||
<tr>
|
||||
<td>{{ document.original_name }}</td>
|
||||
<td>{{ document.scenario_id }}</td>
|
||||
<td>{{ document.file_type }}</td>
|
||||
<td>{{ document.size }}</td>
|
||||
<td>{{ document.get_status_display_text }}</td>
|
||||
<td>
|
||||
{% if document.status != "indexed" %}
|
||||
<form action="{% url 'documents:index' document.id %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit">执行入库</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="status status-success">已可用于检索</span>
|
||||
{% endif %}
|
||||
{% if document.error_message %}
|
||||
<pre class="code-block" style="margin-top: 10px;">{{ document.error_message }}</pre>
|
||||
{% endif %}
|
||||
<p class="muted" style="margin-top: 10px;">上传时间:{{ document.created_at|date:"Y-m-d H:i" }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6">暂无文件,请先上传题目材料。</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -1,38 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}上传文件{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<span class="eyebrow">文件上传</span>
|
||||
<h1 class="page-title">上传题目材料或知识库文档</h1>
|
||||
<p class="page-lead">支持 `.txt`、`.md`、`.pdf` 和 `.docx`。上传后可以在文档中心手动执行入库。</p>
|
||||
</header>
|
||||
|
||||
<article class="panel" style="max-width: 760px;">
|
||||
<form method="post" enctype="multipart/form-data" class="stack">
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
{{ form.scenario_id.label_tag }}
|
||||
{{ form.scenario_id }}
|
||||
{% if form.scenario_id.errors %}
|
||||
<p class="notice notice-error">{{ form.scenario_id.errors|join:" " }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form.file.label_tag }}
|
||||
{{ form.file }}
|
||||
{% if form.file.errors %}
|
||||
<p class="notice notice-error">{{ form.file.errors|join:" " }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if form.errors %}
|
||||
<div class="notice notice-error">{{ form.errors }}</div>
|
||||
{% endif %}
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button type="submit">上传文件</button>
|
||||
<a class="button" href="{% url 'documents:list' %}">返回文件列表</a>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -1,53 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}场景首页{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<span class="eyebrow">场景总览</span>
|
||||
<h1 class="page-title">用同一套底座快速切换不同业务 Agent</h1>
|
||||
<p class="page-lead">当前首页直接读取 YAML 场景配置。你可以从这里进入对话、上传资料,再用审计日志验证整条执行链路。</p>
|
||||
</header>
|
||||
|
||||
{% if scenario_issues %}
|
||||
<section class="panel" style="margin-bottom: 20px;">
|
||||
<h2 class="section-title">配置异常</h2>
|
||||
<p class="muted">以下 YAML 场景文件存在问题,系统已自动跳过,不会影响其它合法场景展示。</p>
|
||||
<ul class="detail-list">
|
||||
{% for issue in scenario_issues %}
|
||||
<li class="detail-item">
|
||||
<strong>{{ issue.file_name }}</strong>
|
||||
<div>{{ issue.message }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card-grid">
|
||||
{% for scenario in scenarios %}
|
||||
<article class="card">
|
||||
<h2>{{ scenario.name }}</h2>
|
||||
<p>{{ scenario.description }}</p>
|
||||
<ul class="meta-list">
|
||||
<li class="meta-badge">场景 ID:{{ scenario.id }}</li>
|
||||
<li class="meta-badge">输出:{{ scenario.output.type }}</li>
|
||||
<li class="meta-badge">RAG:{% if scenario.rag.enabled %}已启用{% else %}未启用{% endif %}</li>
|
||||
<li class="meta-badge">工具数:{{ scenario.tool_count }}</li>
|
||||
</ul>
|
||||
<p class="muted" style="margin-top: 14px;">适用题型:
|
||||
{% if scenario.applicable_questions %}
|
||||
{{ scenario.applicable_questions|join:"、" }}
|
||||
{% else %}
|
||||
暂未配置
|
||||
{% endif %}
|
||||
</p>
|
||||
<p style="margin-top: 16px;">
|
||||
<a class="button button-primary" href="{% url 'chat:index' scenario.id %}">进入对话</a>
|
||||
</p>
|
||||
</article>
|
||||
{% empty %}
|
||||
<div class="notice">暂无可用场景,请检查 `configs/` 目录和 YAML 配置内容。</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,250 +0,0 @@
|
||||
from agent_core.orchestrator import build_messages, run_agent
|
||||
from agent_core.rag.ingest import _split_text, ingest_document
|
||||
from agent_core.rag.retriever import retrieve
|
||||
|
||||
|
||||
def test_run_agent_returns_structured_result_from_llm_output():
|
||||
scenario = {
|
||||
"id": "knowledge_qa",
|
||||
"name": "知识库问答助手",
|
||||
"agent": {
|
||||
"role": "知识库助手",
|
||||
"goal": "基于资料回答问题",
|
||||
"instructions": ["仅根据证据回答"],
|
||||
},
|
||||
"rag": {"enabled": True, "collection": "knowledge_qa", "top_k": 3},
|
||||
"tools": ["generate_action_items"],
|
||||
"output": {"type": "general_answer"},
|
||||
}
|
||||
provider_response = """
|
||||
{
|
||||
"answer": "请先隔离异常现场,再通知负责人。",
|
||||
"confidence": "high",
|
||||
"references": [
|
||||
{"source": "sop.md", "excerpt": "异常处理 SOP:先隔离现场"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
class FakeProvider:
|
||||
def generate(self, messages, response_format=None):
|
||||
from agent_core.llm_provider import LLMResponse
|
||||
|
||||
return LLMResponse(
|
||||
content=provider_response,
|
||||
model_name="demo-model",
|
||||
success=True,
|
||||
)
|
||||
|
||||
result = run_agent(
|
||||
scenario,
|
||||
"如何处理异常?",
|
||||
options={"llm_provider": FakeProvider()},
|
||||
)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.answer == "请先隔离异常现场,再通知负责人。"
|
||||
assert result.structured_output["output_type"] == "general_answer"
|
||||
assert result.structured_output["confidence"] == "high"
|
||||
assert isinstance(result.references, list)
|
||||
assert result.tool_calls[0]["tool_name"] == "generate_action_items"
|
||||
assert result.model_name == "demo-model"
|
||||
|
||||
|
||||
def test_run_agent_falls_back_when_llm_returns_non_json():
|
||||
scenario = {
|
||||
"id": "document_review",
|
||||
"name": "文档审核助手",
|
||||
"agent": {
|
||||
"role": "审核助手",
|
||||
"goal": "总结审核意见",
|
||||
"instructions": ["输出重点问题"],
|
||||
},
|
||||
"rag": {"enabled": False},
|
||||
"tools": [],
|
||||
"output": {"type": "document_review_report"},
|
||||
}
|
||||
|
||||
class FakeProvider:
|
||||
def generate(self, messages, response_format=None):
|
||||
from agent_core.llm_provider import LLMResponse
|
||||
|
||||
return LLMResponse(
|
||||
content="这是非 JSON 的普通回答",
|
||||
model_name="demo-model",
|
||||
success=True,
|
||||
)
|
||||
|
||||
result = run_agent(
|
||||
scenario,
|
||||
"请检查合同风险",
|
||||
options={"llm_provider": FakeProvider()},
|
||||
)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.answer == "这是非 JSON 的普通回答"
|
||||
assert result.structured_output["output_type"] == "document_review_report"
|
||||
assert result.structured_output["summary"] == "这是非 JSON 的普通回答"
|
||||
assert result.structured_output["parse_mode"] == "fallback"
|
||||
|
||||
|
||||
def test_build_messages_contains_role_goal_references_and_tool_results():
|
||||
scenario = {
|
||||
"name": "质量异常分析助手",
|
||||
"agent": {
|
||||
"role": "质量管理专家",
|
||||
"goal": "生成结构化质量分析报告",
|
||||
"instructions": ["必须引用知识库", "缺失信息要说明"],
|
||||
},
|
||||
"output": {"type": "quality_report"},
|
||||
}
|
||||
|
||||
messages = build_messages(
|
||||
scenario_config=scenario,
|
||||
user_input="分析 A 线异常",
|
||||
references=[{"source": "sop.md", "content": "先隔离现场"}],
|
||||
tool_calls=[
|
||||
{
|
||||
"tool_name": "query_demo_records",
|
||||
"success": True,
|
||||
"result": {"records": [{"title": "A线缺陷"}]},
|
||||
"error": "",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert messages[0]["role"] == "system"
|
||||
assert "质量管理专家" in messages[0]["content"]
|
||||
assert "生成结构化质量分析报告" in messages[0]["content"]
|
||||
assert "quality_report" in messages[0]["content"]
|
||||
assert "先隔离现场" in messages[1]["content"]
|
||||
assert "A线缺陷" in messages[1]["content"]
|
||||
assert "分析 A 线异常" in messages[2]["content"]
|
||||
|
||||
|
||||
def test_rag_ingest_and_retrieve_filters_by_scenario_and_query(tmp_path):
|
||||
store_path = tmp_path / "rag_store.json"
|
||||
text = "设备点检需要先断电挂牌。质量异常需要记录批次、工位和缺陷现象。"
|
||||
|
||||
result = ingest_document(
|
||||
scenario_id="quality_analysis",
|
||||
source_file="quality.md",
|
||||
text=text,
|
||||
collection="quality_analysis",
|
||||
store_path=store_path,
|
||||
)
|
||||
ingest_document(
|
||||
scenario_id="risk_audit",
|
||||
source_file="risk.md",
|
||||
text="报销审核需要检查发票、金额和审批链。",
|
||||
collection="risk_audit",
|
||||
store_path=store_path,
|
||||
)
|
||||
|
||||
chunks = retrieve(
|
||||
scenario_id="quality_analysis",
|
||||
query="质量异常批次",
|
||||
collection="quality_analysis",
|
||||
top_k=3,
|
||||
store_path=store_path,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.chunks_count >= 1
|
||||
assert chunks
|
||||
assert chunks[0]["source"] == "quality.md"
|
||||
assert "质量异常" in chunks[0]["content"]
|
||||
assert all(chunk["scenario_id"] == "quality_analysis" for chunk in chunks)
|
||||
|
||||
|
||||
def test_rag_reingest_replaces_same_document_and_retrieve_filters_document_ids(tmp_path):
|
||||
store_path = tmp_path / "rag_store.json"
|
||||
|
||||
ingest_document(
|
||||
document_id=1,
|
||||
scenario_id="knowledge_qa",
|
||||
source_file="old.md",
|
||||
text="旧制度要求人工登记。",
|
||||
collection="knowledge_qa",
|
||||
store_path=store_path,
|
||||
)
|
||||
ingest_document(
|
||||
document_id=1,
|
||||
scenario_id="knowledge_qa",
|
||||
source_file="new.md",
|
||||
text="新制度要求系统自动登记。",
|
||||
collection="knowledge_qa",
|
||||
store_path=store_path,
|
||||
)
|
||||
ingest_document(
|
||||
document_id=2,
|
||||
scenario_id="knowledge_qa",
|
||||
source_file="other.md",
|
||||
text="系统自动登记后需要生成审计记录。",
|
||||
collection="knowledge_qa",
|
||||
store_path=store_path,
|
||||
)
|
||||
|
||||
chunks = retrieve(
|
||||
scenario_id="knowledge_qa",
|
||||
query="系统自动登记",
|
||||
collection="knowledge_qa",
|
||||
top_k=5,
|
||||
document_ids=[1],
|
||||
store_path=store_path,
|
||||
)
|
||||
|
||||
assert chunks
|
||||
assert {chunk["document_id"] for chunk in chunks} == {1}
|
||||
assert all(chunk["source"] == "new.md" for chunk in chunks)
|
||||
assert all("旧制度" not in chunk["content"] for chunk in chunks)
|
||||
|
||||
|
||||
def test_run_agent_uses_retrieved_document_chunks(tmp_path):
|
||||
store_path = tmp_path / "rag_store.json"
|
||||
ingest_document(
|
||||
scenario_id="knowledge_qa",
|
||||
source_file="sop.md",
|
||||
text="异常处理 SOP:先隔离现场,再通知负责人。",
|
||||
collection="knowledge_qa",
|
||||
store_path=store_path,
|
||||
)
|
||||
scenario = {
|
||||
"id": "knowledge_qa",
|
||||
"name": "知识库问答助手",
|
||||
"rag": {"enabled": True, "collection": "knowledge_qa", "top_k": 3},
|
||||
"tools": [],
|
||||
"output": {"type": "general_answer"},
|
||||
}
|
||||
|
||||
result = run_agent(scenario, "异常处理怎么做?", options={"rag_store_path": store_path})
|
||||
|
||||
assert result.references[0]["source"] == "sop.md"
|
||||
assert "隔离现场" in result.references[0]["content"]
|
||||
|
||||
|
||||
def test_rag_split_text_keeps_overlap_and_non_empty_chunks():
|
||||
chunks = _split_text("A" * 20, chunk_size=8, overlap=3)
|
||||
|
||||
assert chunks == ["AAAAAAAA", "AAAAAAAA", "AAAAAAAA", "AAAAA"]
|
||||
|
||||
|
||||
def test_retrieve_returns_empty_when_query_has_no_overlap(tmp_path):
|
||||
store_path = tmp_path / "rag_store.json"
|
||||
ingest_document(
|
||||
scenario_id="knowledge_qa",
|
||||
source_file="rules.md",
|
||||
text="这里描述的是报销流程和审批链。",
|
||||
collection="knowledge_qa",
|
||||
store_path=store_path,
|
||||
)
|
||||
|
||||
chunks = retrieve(
|
||||
scenario_id="knowledge_qa",
|
||||
query="设备点检",
|
||||
collection="knowledge_qa",
|
||||
top_k=3,
|
||||
store_path=store_path,
|
||||
)
|
||||
|
||||
assert chunks == []
|
||||
@@ -1,119 +0,0 @@
|
||||
from django.urls import reverse
|
||||
|
||||
from agent_core.results import AgentResult
|
||||
from apps.audit.models import AgentAuditLog, DemoBusinessRecord
|
||||
from apps.audit.services import create_audit_log
|
||||
from agent_core.tools.builtin_tools import query_demo_records
|
||||
|
||||
|
||||
def test_create_audit_log_records_success_result(db):
|
||||
result = AgentResult(answer="回答", structured_output={"x": 1}, status="success")
|
||||
|
||||
log = create_audit_log("knowledge_qa", "知识库问答助手", "问题", result)
|
||||
|
||||
assert AgentAuditLog.objects.count() == 1
|
||||
assert log.final_answer == "回答"
|
||||
assert log.structured_output == {"x": 1}
|
||||
assert log.status == "success"
|
||||
|
||||
|
||||
def test_audit_list_page_shows_log(client, db):
|
||||
result = AgentResult(answer="回答", status="success")
|
||||
create_audit_log("knowledge_qa", "知识库问答助手", "问题", result)
|
||||
|
||||
response = client.get(reverse("audit:list"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "知识库问答助手" in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_audit_list_can_filter_by_scenario(client, db):
|
||||
create_audit_log(
|
||||
"knowledge_qa",
|
||||
"知识库问答助手",
|
||||
"制度问题",
|
||||
AgentResult(answer="回答一", status="success"),
|
||||
)
|
||||
create_audit_log(
|
||||
"quality_analysis",
|
||||
"质量异常分析助手",
|
||||
"质量问题",
|
||||
AgentResult(answer="回答二", status="success"),
|
||||
)
|
||||
|
||||
response = client.get(reverse("audit:list"), {"scenario_id": "knowledge_qa"})
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert response.status_code == 200
|
||||
assert "知识库问答助手" in content
|
||||
assert "质量异常分析助手" not in content
|
||||
|
||||
|
||||
def test_audit_list_page_shows_user_input_summary(client, db):
|
||||
create_audit_log(
|
||||
"knowledge_qa",
|
||||
"知识库问答助手",
|
||||
"这是一个比较长的用户输入,用于确认列表页会展示输入摘要。",
|
||||
AgentResult(answer="回答", status="success"),
|
||||
)
|
||||
|
||||
response = client.get(reverse("audit:list"))
|
||||
|
||||
assert "这是一个比较长的用户输入" in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_audit_detail_page_shows_raw_output(client, db):
|
||||
result = AgentResult(
|
||||
answer="结构化回答",
|
||||
raw_output='{"answer":"结构化回答","confidence":"high"}',
|
||||
status="success",
|
||||
)
|
||||
log = create_audit_log("knowledge_qa", "知识库问答助手", "问题", result)
|
||||
|
||||
response = client.get(reverse("audit:detail", args=[log.id]))
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert response.status_code == 200
|
||||
assert "原始输出" in content
|
||||
assert "confidence" in content
|
||||
assert "high" in content
|
||||
|
||||
|
||||
def test_create_audit_log_masks_api_keys_from_error_message(db):
|
||||
result = AgentResult(
|
||||
answer="",
|
||||
status="failed",
|
||||
error="LLM_API_KEY=sk-secret-value 调用失败",
|
||||
)
|
||||
|
||||
log = create_audit_log("knowledge_qa", "知识库问答助手", "问题", result)
|
||||
|
||||
assert "sk-secret-value" not in log.error_message
|
||||
assert "sk-***" in log.error_message
|
||||
|
||||
|
||||
def test_create_audit_log_masks_embedding_api_keys_from_error_message(db):
|
||||
result = AgentResult(
|
||||
answer="",
|
||||
status="failed",
|
||||
error="EMBEDDING_API_KEY=embed-secret 调用失败",
|
||||
)
|
||||
|
||||
log = create_audit_log("knowledge_qa", "知识库问答助手", "问题", result)
|
||||
|
||||
assert "embed-secret" not in log.error_message
|
||||
assert "EMBEDDING_API_KEY=***" in log.error_message
|
||||
|
||||
|
||||
def test_query_demo_records_reads_demo_business_record_table(db):
|
||||
DemoBusinessRecord.objects.create(
|
||||
scenario_id="quality_analysis",
|
||||
record_type="defect",
|
||||
title="A线缺陷",
|
||||
payload={"rate": 0.12},
|
||||
)
|
||||
|
||||
result = query_demo_records(user_input="quality_analysis defect")
|
||||
|
||||
assert result["records"][0]["title"] == "A线缺陷"
|
||||
assert result["records"][0]["payload"] == {"rate": 0.12}
|
||||
@@ -1,107 +0,0 @@
|
||||
from django.urls import reverse
|
||||
|
||||
from agent_core.results import AgentResult
|
||||
from apps.audit.models import AgentAuditLog
|
||||
from apps.documents.models import UploadedDocument
|
||||
|
||||
|
||||
def test_chat_post_returns_agent_result_and_audit_log(client, db):
|
||||
response = client.post(
|
||||
reverse("chat:index", args=["knowledge_qa"]),
|
||||
{"message": "如何处理异常?"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "mock-model" in content
|
||||
assert "模拟回答" in content
|
||||
assert AgentAuditLog.objects.count() == 1
|
||||
|
||||
|
||||
def test_chat_rejects_empty_message(client, db):
|
||||
response = client.post(reverse("chat:index", args=["knowledge_qa"]), {"message": ""})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert AgentAuditLog.objects.count() == 0
|
||||
assert "请输入要咨询的问题" in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_chat_passes_selected_document_ids_to_agent_core(client, db, monkeypatch):
|
||||
selected = UploadedDocument.objects.create(
|
||||
scenario_id="knowledge_qa",
|
||||
original_name="selected.md",
|
||||
file_type="md",
|
||||
size=1,
|
||||
status=UploadedDocument.STATUS_INDEXED,
|
||||
)
|
||||
other = UploadedDocument.objects.create(
|
||||
scenario_id="knowledge_qa",
|
||||
original_name="other.md",
|
||||
file_type="md",
|
||||
size=1,
|
||||
status=UploadedDocument.STATUS_INDEXED,
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_run_agent(scenario_config, user_input, options=None):
|
||||
captured["options"] = options or {}
|
||||
from agent_core.results import AgentResult
|
||||
|
||||
return AgentResult(answer="ok", status="success")
|
||||
|
||||
monkeypatch.setattr("apps.chat.views.run_agent", fake_run_agent)
|
||||
|
||||
response = client.post(
|
||||
reverse("chat:index", args=["knowledge_qa"]),
|
||||
{"message": "只查选中文档", "document_ids": [str(selected.id)]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["options"]["document_ids"] == [selected.id]
|
||||
assert other.id not in captured["options"]["document_ids"]
|
||||
|
||||
|
||||
def test_chat_renders_structured_output_references_and_tool_calls(client, db, monkeypatch):
|
||||
def fake_run_agent(scenario_config, user_input, options=None):
|
||||
return AgentResult(
|
||||
answer="建议先隔离现场。",
|
||||
structured_output={
|
||||
"output_type": "quality_report",
|
||||
"summary": "发现异常批次需要立即处置。",
|
||||
"risk_level": "high",
|
||||
"suggested_actions": ["隔离现场", "通知负责人"],
|
||||
},
|
||||
references=[
|
||||
{
|
||||
"source": "sop.md",
|
||||
"content": "异常处理 SOP:先隔离现场,再通知负责人。",
|
||||
}
|
||||
],
|
||||
tool_calls=[
|
||||
{
|
||||
"tool_name": "query_demo_records",
|
||||
"success": True,
|
||||
"result": {"records": [{"title": "A线缺陷"}]},
|
||||
"error": "",
|
||||
}
|
||||
],
|
||||
model_name="mock-model",
|
||||
status="success",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("apps.chat.views.run_agent", fake_run_agent)
|
||||
|
||||
response = client.post(
|
||||
reverse("chat:index", args=["quality_analysis"]),
|
||||
{"message": "分析 A 线异常"},
|
||||
)
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert response.status_code == 200
|
||||
assert "结构化结果" in content
|
||||
assert "发现异常批次需要立即处置" in content
|
||||
assert "引用片段" in content
|
||||
assert "sop.md" in content
|
||||
assert "工具调用" in content
|
||||
assert "query_demo_records" in content
|
||||
assert "查看本次审计日志" in content
|
||||
@@ -1,147 +0,0 @@
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.documents.forms import DocumentUploadForm
|
||||
from apps.documents.models import UploadedDocument
|
||||
from apps.documents.services import extract_text, index_document
|
||||
|
||||
|
||||
def test_upload_txt_document_creates_uploaded_record(client, db):
|
||||
file = SimpleUploadedFile("rules.txt", "hello".encode("utf-8"), content_type="text/plain")
|
||||
|
||||
response = client.post(
|
||||
reverse("documents:upload"),
|
||||
{"scenario_id": "knowledge_qa", "file": file},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
document = UploadedDocument.objects.get()
|
||||
assert document.status == "uploaded"
|
||||
assert document.file_type == "txt"
|
||||
assert document.scenario_id == "knowledge_qa"
|
||||
|
||||
|
||||
def test_upload_redirect_shows_success_message(client, db):
|
||||
file = SimpleUploadedFile("notice.txt", "hello".encode("utf-8"), content_type="text/plain")
|
||||
|
||||
response = client.post(
|
||||
reverse("documents:upload"),
|
||||
{"scenario_id": "knowledge_qa", "file": file},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "文件已上传,可继续执行入库" in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_upload_accepts_pdf_and_docx_documents(client, db):
|
||||
for filename, payload in [
|
||||
("policy.pdf", b"%PDF-1.4\nplain policy text"),
|
||||
("contract.docx", b"fake-docx-body"),
|
||||
]:
|
||||
file = SimpleUploadedFile(filename, payload)
|
||||
|
||||
response = client.post(
|
||||
reverse("documents:upload"),
|
||||
{"scenario_id": "knowledge_qa", "file": file},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
assert set(UploadedDocument.objects.values_list("file_type", flat=True)) == {"pdf", "docx"}
|
||||
|
||||
|
||||
def test_index_document_updates_status_to_indexed(client, db):
|
||||
document = UploadedDocument.objects.create(
|
||||
scenario_id="knowledge_qa",
|
||||
original_name="rules.md",
|
||||
file="knowledge_qa/rules.md",
|
||||
file_type="md",
|
||||
size=5,
|
||||
status="uploaded",
|
||||
)
|
||||
document.file.save("rules.md", SimpleUploadedFile("rules.md", b"# rule").file)
|
||||
|
||||
response = client.post(reverse("documents:index", args=[document.id]))
|
||||
|
||||
assert response.status_code == 302
|
||||
document.refresh_from_db()
|
||||
assert document.status == "indexed"
|
||||
assert document.error_message == ""
|
||||
|
||||
|
||||
def test_extract_text_supports_pdf_and_docx_plain_text_fallback(db):
|
||||
pdf_document = UploadedDocument.objects.create(
|
||||
scenario_id="knowledge_qa",
|
||||
original_name="policy.pdf",
|
||||
file_type="pdf",
|
||||
size=10,
|
||||
status="uploaded",
|
||||
)
|
||||
pdf_document.file.save("policy.pdf", SimpleUploadedFile("policy.pdf", b"%PDF-1.4\nSafety policy"))
|
||||
|
||||
docx_document = UploadedDocument.objects.create(
|
||||
scenario_id="knowledge_qa",
|
||||
original_name="contract.docx",
|
||||
file_type="docx",
|
||||
size=10,
|
||||
status="uploaded",
|
||||
)
|
||||
docx_document.file.save(
|
||||
"contract.docx",
|
||||
SimpleUploadedFile("contract.docx", b"Contract clause review"),
|
||||
)
|
||||
|
||||
assert "Safety policy" in extract_text(pdf_document)
|
||||
assert "Contract clause review" in extract_text(docx_document)
|
||||
|
||||
|
||||
def test_document_upload_form_builds_scenario_choices():
|
||||
form = DocumentUploadForm()
|
||||
|
||||
choice_values = [value for value, _label in form.fields["scenario_id"].choices]
|
||||
|
||||
assert "knowledge_qa" in choice_values
|
||||
assert "quality_analysis" in choice_values
|
||||
|
||||
|
||||
def test_index_failure_message_is_visible_on_document_list(client, db, monkeypatch):
|
||||
document = UploadedDocument.objects.create(
|
||||
scenario_id="knowledge_qa",
|
||||
original_name="broken.md",
|
||||
file_type="md",
|
||||
size=5,
|
||||
status="uploaded",
|
||||
)
|
||||
|
||||
def fake_index_document(target_document):
|
||||
target_document.status = UploadedDocument.STATUS_FAILED
|
||||
target_document.error_message = "模拟入库失败"
|
||||
target_document.save(update_fields=["status", "error_message", "updated_at"])
|
||||
return target_document
|
||||
|
||||
monkeypatch.setattr("apps.documents.views.index_document", fake_index_document)
|
||||
|
||||
response = client.post(reverse("documents:index", args=[document.id]), follow=True)
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert response.status_code == 200
|
||||
assert "文档入库失败,请检查错误原因后重试" in content
|
||||
assert "模拟入库失败" in content
|
||||
|
||||
|
||||
def test_index_document_marks_failed_when_extracted_text_is_empty(db, monkeypatch):
|
||||
document = UploadedDocument.objects.create(
|
||||
scenario_id="knowledge_qa",
|
||||
original_name="empty.md",
|
||||
file_type="md",
|
||||
size=0,
|
||||
status="uploaded",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("apps.documents.services.extract_text", lambda target: " ")
|
||||
|
||||
updated_document = index_document(document)
|
||||
|
||||
assert updated_document.status == UploadedDocument.STATUS_FAILED
|
||||
assert "文档内容为空" in updated_document.error_message
|
||||
@@ -1,126 +0,0 @@
|
||||
from agent_core.llm_provider import (
|
||||
EmbeddingConfigurationError,
|
||||
LLMConfigurationError,
|
||||
create_embedding_provider,
|
||||
create_llm_provider,
|
||||
get_runtime_llm_config,
|
||||
)
|
||||
|
||||
|
||||
def test_create_llm_provider_requires_api_key_for_openai_compatible():
|
||||
provider = create_llm_provider(
|
||||
{
|
||||
"LLM_API_KEY": "",
|
||||
"LLM_BASE_URL": "https://api.openai.com/v1",
|
||||
"LLM_MODEL": "gpt-4.1-mini",
|
||||
"LLM_PROVIDER": "openai_compatible",
|
||||
}
|
||||
)
|
||||
|
||||
response = provider.generate([{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.success is False
|
||||
assert isinstance(response.error, LLMConfigurationError)
|
||||
assert "LLM_API_KEY" in str(response.error)
|
||||
|
||||
|
||||
def test_mock_provider_returns_deterministic_content():
|
||||
provider = create_llm_provider({"LLM_PROVIDER": "mock", "LLM_MODEL": "demo-model"})
|
||||
|
||||
response = provider.generate([{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.success is True
|
||||
assert response.model_name == "demo-model"
|
||||
assert "hello" in response.content
|
||||
|
||||
|
||||
def test_openai_compatible_provider_posts_chat_completion(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b'{"choices":[{"message":{"content":"ok"}}],"model":"demo-model"}'
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = dict(request.header_items())
|
||||
captured["body"] = request.data.decode("utf-8")
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("agent_core.llm_provider.urlopen", fake_urlopen)
|
||||
provider = create_llm_provider(
|
||||
{
|
||||
"LLM_PROVIDER": "openai_compatible",
|
||||
"LLM_API_KEY": "sk-test",
|
||||
"LLM_BASE_URL": "https://api.siliconflow.cn/v1",
|
||||
"LLM_MODEL": "demo-model",
|
||||
}
|
||||
)
|
||||
|
||||
response = provider.generate([{"role": "user", "content": "hello"}])
|
||||
|
||||
assert response.success is True
|
||||
assert response.content == "ok"
|
||||
assert captured["url"] == "https://api.siliconflow.cn/v1/chat/completions"
|
||||
assert '"model": "demo-model"' in captured["body"]
|
||||
assert captured["headers"]["Authorization"] == "Bearer sk-test"
|
||||
|
||||
|
||||
def test_embedding_provider_uses_openai_compatible_embeddings(monkeypatch):
|
||||
class FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b'{"data":[{"embedding":[0.1,0.2]},{"embedding":[0.3,0.4]}]}'
|
||||
|
||||
monkeypatch.setattr("agent_core.llm_provider.urlopen", lambda request, timeout: FakeResponse())
|
||||
provider = create_embedding_provider(
|
||||
{
|
||||
"EMBEDDING_API_KEY": "sk-test",
|
||||
"EMBEDDING_BASE_URL": "https://api.siliconflow.cn/v1",
|
||||
"EMBEDDING_MODEL": "demo-embedding",
|
||||
}
|
||||
)
|
||||
|
||||
assert provider.embed_texts(["a", "b"]) == [[0.1, 0.2], [0.3, 0.4]]
|
||||
|
||||
|
||||
def test_embedding_provider_requires_api_key():
|
||||
provider = create_embedding_provider(
|
||||
{
|
||||
"EMBEDDING_API_KEY": "",
|
||||
"EMBEDDING_BASE_URL": "https://api.siliconflow.cn/v1",
|
||||
"EMBEDDING_MODEL": "demo-embedding",
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
provider.embed_texts(["a"])
|
||||
except EmbeddingConfigurationError as exc:
|
||||
assert "EMBEDDING_API_KEY" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected EmbeddingConfigurationError")
|
||||
|
||||
|
||||
def test_get_runtime_llm_config_uses_environment_and_overrides(monkeypatch):
|
||||
monkeypatch.setenv("LLM_PROVIDER", "mock")
|
||||
monkeypatch.setenv("LLM_API_KEY", "sk-env")
|
||||
monkeypatch.setenv("LLM_BASE_URL", "https://env.example/v1")
|
||||
monkeypatch.setenv("LLM_MODEL", "env-model")
|
||||
|
||||
config = get_runtime_llm_config({"LLM_MODEL": "override-model"})
|
||||
|
||||
assert config["LLM_PROVIDER"] == "mock"
|
||||
assert config["LLM_API_KEY"] == "sk-env"
|
||||
assert config["LLM_BASE_URL"] == "https://env.example/v1"
|
||||
assert config["LLM_MODEL"] == "override-model"
|
||||
@@ -1,21 +0,0 @@
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
def test_core_settings_expose_documented_paths():
|
||||
assert settings.SCENARIO_CONFIG_DIR.name == "configs"
|
||||
assert settings.CHROMA_PATH.name == "chroma"
|
||||
assert settings.MEDIA_ROOT.name == "uploads"
|
||||
assert settings.EMBEDDING_MODEL == os.environ.get(
|
||||
"EMBEDDING_MODEL",
|
||||
"text-embedding-3-small",
|
||||
)
|
||||
assert settings.EMBEDDING_BASE_URL == settings.LLM_BASE_URL
|
||||
assert settings.EMBEDDING_API_KEY == settings.LLM_API_KEY
|
||||
|
||||
|
||||
def test_home_url_is_registered(client):
|
||||
response = client.get(reverse("scenarios:index"))
|
||||
assert response.status_code == 200
|
||||
@@ -1,129 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from apps.scenarios.services import (
|
||||
ScenarioNotFound,
|
||||
get_scenario,
|
||||
list_scenario_issues,
|
||||
list_scenarios,
|
||||
)
|
||||
|
||||
|
||||
def test_list_scenarios_loads_five_configs():
|
||||
scenarios = list_scenarios()
|
||||
assert [scenario["id"] for scenario in scenarios] == [
|
||||
"document_review",
|
||||
"knowledge_qa",
|
||||
"quality_analysis",
|
||||
"risk_audit",
|
||||
"ticket_assistant",
|
||||
]
|
||||
|
||||
|
||||
def test_get_scenario_returns_full_agent_config():
|
||||
scenario = get_scenario("quality_analysis")
|
||||
assert scenario["agent"]["role"] == "质量管理专家"
|
||||
assert scenario["rag"]["enabled"] is True
|
||||
assert scenario["output"]["type"] == "quality_report"
|
||||
assert "质量异常分析" in scenario["applicable_questions"][0]
|
||||
|
||||
|
||||
def test_get_scenario_raises_clear_error_for_missing_id():
|
||||
with pytest.raises(ScenarioNotFound, match="场景不存在"):
|
||||
get_scenario("missing")
|
||||
|
||||
|
||||
def test_home_page_shows_applicable_questions(client):
|
||||
response = client.get("/")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert response.status_code == 200
|
||||
assert "适用题型" in content
|
||||
assert "SOP 问答" in content
|
||||
|
||||
|
||||
def test_list_scenarios_skips_invalid_config_and_collects_issues(settings, tmp_path):
|
||||
valid_file = tmp_path / "valid.yaml"
|
||||
invalid_file = tmp_path / "invalid.yaml"
|
||||
valid_file.write_text(
|
||||
"""
|
||||
id: demo_valid
|
||||
name: 有效场景
|
||||
description: 用于测试
|
||||
agent:
|
||||
role: 测试助手
|
||||
goal: 正常返回
|
||||
instructions:
|
||||
- 输出结果
|
||||
rag:
|
||||
enabled: false
|
||||
tools: []
|
||||
output:
|
||||
type: general_answer
|
||||
audit:
|
||||
enabled: true
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
invalid_file.write_text(
|
||||
"""
|
||||
id: broken
|
||||
name: 非法场景
|
||||
description: 缺少 agent.goal
|
||||
agent:
|
||||
role: 测试助手
|
||||
instructions:
|
||||
- 输出结果
|
||||
rag:
|
||||
enabled: true
|
||||
tools: []
|
||||
output:
|
||||
type: general_answer
|
||||
audit:
|
||||
enabled: true
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings.SCENARIO_CONFIG_DIR = tmp_path
|
||||
|
||||
scenarios = list_scenarios()
|
||||
issues = list_scenario_issues()
|
||||
|
||||
assert [scenario["id"] for scenario in scenarios] == ["demo_valid"]
|
||||
assert len(issues) == 1
|
||||
assert issues[0]["file_name"] == "invalid.yaml"
|
||||
assert "agent.goal" in issues[0]["message"]
|
||||
|
||||
|
||||
def test_home_page_shows_invalid_scenario_issues_instead_of_500(client, settings, tmp_path):
|
||||
valid_file = tmp_path / "valid.yaml"
|
||||
invalid_file = tmp_path / "invalid.yaml"
|
||||
valid_file.write_text(
|
||||
"""
|
||||
id: demo_valid
|
||||
name: 有效场景
|
||||
description: 用于测试
|
||||
agent:
|
||||
role: 测试助手
|
||||
goal: 正常返回
|
||||
instructions:
|
||||
- 输出结果
|
||||
rag:
|
||||
enabled: false
|
||||
tools: []
|
||||
output:
|
||||
type: general_answer
|
||||
audit:
|
||||
enabled: true
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
invalid_file.write_text("id: broken\nname: 缺失结构", encoding="utf-8")
|
||||
settings.SCENARIO_CONFIG_DIR = tmp_path
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert response.status_code == 200
|
||||
assert "有效场景" in content
|
||||
assert "配置异常" in content
|
||||
assert "invalid.yaml" in content
|
||||
@@ -1,54 +0,0 @@
|
||||
from agent_core.tool_registry import ToolRegistry, run_declared_tools
|
||||
from agent_core.tools.builtin_tools import calculate_rate, check_required_fields
|
||||
|
||||
|
||||
def test_tool_registry_register_get_and_run():
|
||||
registry = ToolRegistry()
|
||||
|
||||
def hello_tool(user_input: str) -> dict:
|
||||
return {"echo": user_input}
|
||||
|
||||
registry.register("hello", hello_tool)
|
||||
|
||||
assert registry.get("hello") is hello_tool
|
||||
assert registry.run("hello", user_input="demo") == {
|
||||
"tool_name": "hello",
|
||||
"success": True,
|
||||
"arguments": {"user_input": "demo"},
|
||||
"result": {"echo": "demo"},
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def test_tool_registry_returns_failed_result_for_missing_tool():
|
||||
registry = ToolRegistry()
|
||||
|
||||
result = registry.run("missing", user_input="demo")
|
||||
|
||||
assert result["tool_name"] == "missing"
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "工具未注册"
|
||||
|
||||
|
||||
def test_run_declared_tools_executes_multiple_tools_in_order():
|
||||
results = run_declared_tools(["generate_action_items", "missing_tool"], "请生成行动项")
|
||||
|
||||
assert [item["tool_name"] for item in results] == ["generate_action_items", "missing_tool"]
|
||||
assert results[0]["success"] is True
|
||||
assert results[1]["success"] is False
|
||||
|
||||
|
||||
def test_calculate_rate_extracts_fraction_like_numbers():
|
||||
result = calculate_rate("产线合格率,已完成 18 件,总数 24 件")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["numerator"] == 18.0
|
||||
assert result["denominator"] == 24.0
|
||||
assert result["rate"] == 0.75
|
||||
|
||||
|
||||
def test_check_required_fields_reports_missing_fields():
|
||||
result = check_required_fields("请检查必填项:合同编号、供应商、金额。当前只提供了合同编号和金额。")
|
||||
|
||||
assert "供应商" in result["missing_fields"]
|
||||
assert "合同编号" not in result["missing_fields"]
|
||||
Reference in New Issue
Block a user