530 lines
17 KiB
Python
530 lines
17 KiB
Python
"""Agent 协调器
|
||
|
||
作为调度中枢,编排信息提取、规则校验的完整流程。
|
||
|
||
校验-修正循环由 Agent 层调度:
|
||
1. Agent 调用 LLM 提取信息
|
||
2. Agent 调用 validator.py 校验
|
||
3. 校验失败则构建修正提示,再次调用 LLM
|
||
4. 重复直到校验通过或达到最大重试次数
|
||
5. LLM 在输出中包含 can_submit 和 suggestion 字段,用于判断信息完整性
|
||
|
||
状态机:
|
||
idle -> extracting -> awaiting_supplement -> (回到extracting)
|
||
|
|
||
(完整) -> ready -> submitting -> done
|
||
|
|
||
(用户强制) -> ready
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import asdict, dataclass, field
|
||
from enum import StrEnum
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from .. import get_logger
|
||
from ..doc.llm_extractor import (
|
||
build_extraction_user_message,
|
||
llm_query_text,
|
||
load_cache,
|
||
load_match_result,
|
||
parse_json_response,
|
||
)
|
||
from ..doc.prompt import (
|
||
build_normal_info_system_prompt,
|
||
build_travel_info_system_prompt,
|
||
)
|
||
from ..doc.validator import validate_extracted_info
|
||
from ..pipeline_core import save_cache_info
|
||
|
||
log = get_logger("agent")
|
||
|
||
# 规则校验-修正循环的最大重试次数
|
||
MAX_VALIDATION_RETRIES = 3
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 状态枚举
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
class AgentState(StrEnum):
|
||
IDLE = "idle"
|
||
EXTRACTING = "extracting"
|
||
AWAITING_SUPPLEMENT = "awaiting_supplement"
|
||
READY = "ready"
|
||
SUBMITTING = "submitting"
|
||
DONE = "done"
|
||
ERROR = "error"
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Agent 会话数据模型
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class AgentSession:
|
||
"""Agent 会话状态"""
|
||
|
||
session_id: str
|
||
state: AgentState = AgentState.IDLE
|
||
rounds: int = 0
|
||
max_rounds: int = 5
|
||
invoice_type: str = "travel" # "travel" 或 "normal"
|
||
extracted_info: dict[str, Any] = field(default_factory=dict)
|
||
validation_reports: list[dict[str, Any]] = field(default_factory=list)
|
||
user_supplements: list[str] = field(default_factory=list)
|
||
error_message: str = ""
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
return asdict(self)
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: dict[str, Any]) -> AgentSession:
|
||
# 兼容旧版本:state 可能是字符串
|
||
if "state" in data and isinstance(data["state"], str):
|
||
data["state"] = AgentState(data["state"])
|
||
return cls(**data)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 持久化
|
||
# ------------------------------------------------------------------
|
||
|
||
AGENT_STATE_FILE = "agent_state.json"
|
||
|
||
|
||
def save_agent_state(session_dir: Path, session: AgentSession) -> None:
|
||
"""将 Agent 会话状态持久化到 session 目录。"""
|
||
state_path = session_dir / AGENT_STATE_FILE
|
||
tmp_path = session_dir / (AGENT_STATE_FILE + ".tmp")
|
||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||
json.dump(session.to_dict(), f, ensure_ascii=False, indent=2)
|
||
tmp_path.replace(state_path)
|
||
|
||
|
||
def load_agent_state(session_dir: Path) -> AgentSession | None:
|
||
"""从 session 目录加载 Agent 会话状态。"""
|
||
state_path = session_dir / AGENT_STATE_FILE
|
||
if not state_path.exists():
|
||
return None
|
||
try:
|
||
with open(state_path, encoding="utf-8") as f:
|
||
return AgentSession.from_dict(json.load(f))
|
||
except Exception as e:
|
||
log.warning("加载 Agent 状态失败: %s", e)
|
||
return None
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# SSE 事件发射
|
||
# ------------------------------------------------------------------
|
||
|
||
AGENT_EVENT_LOG = "agent_events.log"
|
||
|
||
# 去重守卫:记录每个 session 上一次发射的事件类型,防止连续重复发射
|
||
# key: str(session_dir), value: 上一次的 event_type
|
||
_last_event_type: dict[str, str] = {}
|
||
|
||
|
||
def _emit_agent_event(session_dir: Path, event_type: str, **kwargs: Any) -> None:
|
||
"""向 agent_events.log 追加一行 JSON 事件。
|
||
|
||
同一 session 连续发射相同 event_type 时直接抛出 RuntimeError,
|
||
强制调用方修复重复发射的代码,而非静默掩盖。
|
||
"""
|
||
session_key = str(session_dir)
|
||
prev = _last_event_type.get(session_key)
|
||
if prev == event_type:
|
||
raise RuntimeError(
|
||
f"事件重复发射: session={session_dir.name!r}, event_type={event_type!r}。"
|
||
f"请检查调用链,确保每个事件类型只发射一次。"
|
||
)
|
||
_last_event_type[session_key] = event_type
|
||
|
||
event = {"type": event_type, **kwargs}
|
||
try:
|
||
event_path = session_dir / AGENT_EVENT_LOG
|
||
with open(event_path, "a", encoding="utf-8") as f:
|
||
f.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 辅助:构建修正提示
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
def _build_correction_prompt(
|
||
base_message: str,
|
||
report: Any,
|
||
) -> str:
|
||
"""根据校验报告构建修正提示,追加到原始用户消息后。"""
|
||
error_feedback = (
|
||
f"\n\n=== 上一次输出的校验结果 ===\n"
|
||
f"校验未通过,发现以下问题:\n"
|
||
f"缺失字段 ({len(report.missing_fields)} 个):{', '.join(report.missing_fields)}\n"
|
||
)
|
||
if report.missing_materials:
|
||
error_feedback += f"可能需要补充的材料:{', '.join(report.missing_materials)}\n"
|
||
if report.suggestion:
|
||
error_feedback += f"建议:{report.suggestion}\n"
|
||
error_feedback += (
|
||
"\n请根据以上校验结果修正你的输出,确保所有必填字段都有值。"
|
||
"如果某个字段确实没有数据,请给出合理的猜测值。"
|
||
"再次返回完整的 JSON 结果。"
|
||
)
|
||
return base_message + error_feedback
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 核心协调逻辑
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
def _do_extraction_with_validation(
|
||
session_dir: Path,
|
||
session: AgentSession,
|
||
previous_analysis: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Agent 调度的提取-校验-修正循环。
|
||
|
||
流程:
|
||
1. 加载缓存数据和匹配结果
|
||
2. 构建用户消息
|
||
3. 调用 LLM 提取
|
||
4. 调用 validator 校验
|
||
5. 校验失败则构建修正提示,回到步骤 3
|
||
6. 最多重试 MAX_VALIDATION_RETRIES 次
|
||
|
||
LLM 输出的 JSON 中额外包含 can_submit 和 suggestion 字段,
|
||
用于判断信息是否完整可提交。
|
||
|
||
Args:
|
||
session_dir: 会话目录。
|
||
session: 当前 Agent 会话。
|
||
previous_analysis: 上一轮 LLM 分析结果(可选,补充文件时传入作为历史上下文)。
|
||
|
||
Returns:
|
||
校验通过的结构化数据(或达到重试上限后的最佳结果)。
|
||
"""
|
||
cache_map = load_cache(session_dir)
|
||
match_result = load_match_result(session_dir)
|
||
|
||
# 选择系统提示词
|
||
if session.invoice_type == "travel":
|
||
system_prompt = build_travel_info_system_prompt()
|
||
else:
|
||
system_prompt = build_normal_info_system_prompt()
|
||
|
||
base_message = build_extraction_user_message(cache_map, match_result, previous_analysis=previous_analysis)
|
||
current_message = base_message
|
||
|
||
for attempt in range(1, MAX_VALIDATION_RETRIES + 1):
|
||
log.info(
|
||
"LLM 提取第 %d/%d 次尝试 (%s)",
|
||
attempt,
|
||
MAX_VALIDATION_RETRIES,
|
||
session.invoice_type,
|
||
)
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_state_change",
|
||
state=AgentState.EXTRACTING,
|
||
round=session.rounds,
|
||
attempt=attempt,
|
||
message=f"正在分析文件... (第{attempt}次)",
|
||
)
|
||
|
||
# Step 1: 调用 LLM 提取
|
||
try:
|
||
response = llm_query_text(
|
||
system_prompt=system_prompt,
|
||
text=current_message,
|
||
reasoning_effort="low",
|
||
source_dir=session_dir,
|
||
)
|
||
result = parse_json_response(response)
|
||
except Exception as e:
|
||
log.error("LLM 提取失败: %s", e)
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_error",
|
||
message=f"LLM 提取失败: {e}",
|
||
)
|
||
raise
|
||
|
||
# Step 2: 调用 validator 校验
|
||
report = validate_extracted_info(result, invoice_type=session.invoice_type)
|
||
|
||
if report.valid:
|
||
log.info("规则校验通过 (第 %d 次尝试)", attempt)
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_state_change",
|
||
state=AgentState.EXTRACTING,
|
||
round=session.rounds,
|
||
attempt=attempt,
|
||
message=f"规则校验通过 (第{attempt}次)",
|
||
)
|
||
return result
|
||
|
||
# Step 3: 校验失败,构建修正提示
|
||
log.warning(
|
||
"规则校验未通过 (第 %d/%d 次): 缺失 %d 个字段 - %s",
|
||
attempt,
|
||
MAX_VALIDATION_RETRIES,
|
||
len(report.missing_fields),
|
||
report.missing_fields,
|
||
)
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_state_change",
|
||
state=AgentState.EXTRACTING,
|
||
round=session.rounds,
|
||
attempt=attempt,
|
||
message=f"规则校验未通过,缺失 {len(report.missing_fields)} 个字段,正在请求 LLM 修正...",
|
||
)
|
||
current_message = _build_correction_prompt(current_message, report)
|
||
|
||
# 所有重试都失败,返回最后一次结果
|
||
log.error(
|
||
"LLM 提取经过 %d 次尝试仍未通过规则校验,返回最后一次结果 (置信度: %.0f%%)",
|
||
MAX_VALIDATION_RETRIES,
|
||
report.confidence * 100,
|
||
)
|
||
return result
|
||
|
||
|
||
def run_agent_round(
|
||
session_dir: Path,
|
||
session: AgentSession,
|
||
new_files: list[str] | None = None,
|
||
) -> AgentSession:
|
||
"""执行一轮 Agent 处理:提取-校验-修正循环。
|
||
|
||
Args:
|
||
session_dir: 会话目录。
|
||
session: 当前 Agent 会话。
|
||
new_files: 新增的文件列表(可选,补充文件时传入)。
|
||
|
||
Returns:
|
||
更新后的 Agent 会话。
|
||
|
||
注意:
|
||
- 信息提取优先从 .invoice_cache 缓存读取,避免重复调用 LLM。
|
||
- Agent 调度提取-校验-修正循环:LLM 提取 -> validator 校验 -> 失败则反馈修正。
|
||
- 若缓存缺失则执行提取后立即写回缓存(travel_info.json / normal_info.json)。
|
||
- 补充文件时(new_files 非空),加载上一轮分析结果作为历史上下文,强制重新分析。
|
||
"""
|
||
# 终态保护:会话已提交或已完成时不再重复处理
|
||
if session.state in (AgentState.DONE, AgentState.SUBMITTING, AgentState.READY):
|
||
log.info("Agent 会话已处于终态 (%s),跳过重复处理", session.state.value)
|
||
return session
|
||
|
||
if session.rounds >= session.max_rounds:
|
||
session.state = AgentState.ERROR
|
||
session.error_message = f"已达到最大轮次 ({session.max_rounds}),请检查信息或强制提交"
|
||
log.warning("Agent 达到最大轮次限制")
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_max_rounds",
|
||
message=session.error_message,
|
||
)
|
||
return session
|
||
|
||
session.rounds += 1
|
||
log.info("开始第 %d 轮 Agent 处理", session.rounds)
|
||
|
||
# ---- Step 1: 信息提取(Agent 调度校验-修正循环) ----
|
||
session.state = AgentState.EXTRACTING
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_state_change",
|
||
state=session.state,
|
||
round=session.rounds,
|
||
message="正在分析文件...",
|
||
)
|
||
|
||
# 判断是否为补充文件场景:有新文件传入时,加载上一轮分析结果作为上下文
|
||
cache_map = load_cache(session_dir)
|
||
is_supplement = bool(new_files)
|
||
previous_analysis = None
|
||
if is_supplement:
|
||
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
|
||
previous_analysis = cache_map.get(info_key)
|
||
if previous_analysis:
|
||
log.info("检测到补充文件,加载上一轮分析结果作为历史上下文")
|
||
|
||
try:
|
||
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
|
||
should_reanalyze = not cache_map.get(info_key) or is_supplement
|
||
if should_reanalyze:
|
||
session.extracted_info = _do_extraction_with_validation(
|
||
session_dir, session, previous_analysis=previous_analysis
|
||
)
|
||
# 提取后立即写入缓存,后续步骤依赖此数据
|
||
save_cache_info(session_dir, info_key, session.extracted_info)
|
||
else:
|
||
session.extracted_info = cache_map[info_key]
|
||
|
||
except Exception as e:
|
||
session.state = AgentState.ERROR
|
||
session.error_message = f"信息提取失败: {e}"
|
||
log.error("Agent 信息提取失败: %s", e)
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_error",
|
||
message=session.error_message,
|
||
)
|
||
return session
|
||
|
||
# ---- 判断结果(从 LLM 提取结果中读 can_submit) ----
|
||
can_submit = session.extracted_info.get("can_submit", True)
|
||
suggestion = session.extracted_info.get("suggestion", "")
|
||
|
||
if can_submit:
|
||
session.state = AgentState.READY
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_ready",
|
||
round=session.rounds,
|
||
message="信息完整,可以提交",
|
||
)
|
||
log.info("Agent 校验通过,信息完整")
|
||
else:
|
||
session.state = AgentState.AWAITING_SUPPLEMENT
|
||
|
||
combined_suggestion = suggestion or "信息不完整,请补充材料"
|
||
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_request_supplement",
|
||
round=session.rounds,
|
||
missing_fields=[],
|
||
missing_materials=[],
|
||
semantic_issues=[],
|
||
suggestion=combined_suggestion,
|
||
)
|
||
log.info("Agent 请求补充: %s", combined_suggestion)
|
||
|
||
save_agent_state(session_dir, session)
|
||
return session
|
||
|
||
|
||
def force_submit(
|
||
session_dir: Path,
|
||
session: AgentSession,
|
||
) -> AgentSession:
|
||
"""用户强制提交,跳过校验。"""
|
||
session.state = AgentState.READY
|
||
log.info("用户强制提交,跳过校验")
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_force_submit",
|
||
message="用户选择强制提交",
|
||
)
|
||
save_agent_state(session_dir, session)
|
||
return session
|
||
|
||
|
||
def add_supplement(
|
||
session_dir: Path,
|
||
session: AgentSession,
|
||
filenames: list[str],
|
||
) -> AgentSession:
|
||
"""记录用户补充的文件。"""
|
||
session.user_supplements.extend(filenames)
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_supplement_received",
|
||
files=filenames,
|
||
)
|
||
log.info("收到用户补充文件: %s", filenames)
|
||
save_agent_state(session_dir, session)
|
||
return session
|
||
|
||
|
||
def process_user_text_supplement(
|
||
session_dir: Path,
|
||
session: AgentSession,
|
||
user_text: str,
|
||
) -> AgentSession:
|
||
"""处理用户通过文字补充的信息。
|
||
|
||
流程:
|
||
1. LLM 分析用户文字,提取需要更新的字段
|
||
2. 合并到已提取的信息中
|
||
3. 保存到缓存
|
||
4. 重新执行一轮 Agent 校验
|
||
|
||
Args:
|
||
session_dir: 会话目录。
|
||
session: 当前 Agent 会话。
|
||
user_text: 用户输入的文字。
|
||
|
||
Returns:
|
||
更新后的 Agent 会话。
|
||
"""
|
||
from ..doc.llm_extractor import (
|
||
merge_supplement_into_info,
|
||
process_user_supplement,
|
||
)
|
||
|
||
log.info("收到用户文字补充: %s", user_text)
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_supplement_received",
|
||
files=[user_text[:50]], # 简短显示
|
||
)
|
||
|
||
# Step 1: LLM 分析用户文字
|
||
supplement_result = process_user_supplement(
|
||
user_text=user_text,
|
||
extracted_info=session.extracted_info,
|
||
invoice_type=session.invoice_type,
|
||
source_dir=session_dir,
|
||
)
|
||
|
||
updated_fields = supplement_result.get("updated_fields", {})
|
||
unparsed = supplement_result.get("unparsed_info", "")
|
||
|
||
if updated_fields:
|
||
# Step 2: 合并到已提取信息
|
||
session.extracted_info = merge_supplement_into_info(
|
||
session.extracted_info,
|
||
updated_fields,
|
||
)
|
||
|
||
# Step 3: 保存到缓存
|
||
info_key = "travel_info" if session.invoice_type == "travel" else "normal_info"
|
||
save_cache_info(session_dir, info_key, session.extracted_info)
|
||
log.info("已更新 %s", info_key)
|
||
|
||
# Step 4: 重新执行 Agent 校验
|
||
session.state = AgentState.EXTRACTING
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_state_change",
|
||
state=session.state,
|
||
round=session.rounds,
|
||
message="正在重新校验...",
|
||
)
|
||
session = run_agent_round(session_dir, session)
|
||
else:
|
||
# 没有可更新的字段
|
||
msg = unparsed or "未识别到可更新的报销信息"
|
||
_emit_agent_event(
|
||
session_dir,
|
||
"agent_supplement_received",
|
||
files=[msg],
|
||
)
|
||
log.info("用户补充未识别到有效信息: %s", msg)
|
||
|
||
return session
|